diff --git a/.github/workflows/deploy-documentation.yml b/.github/workflows/deploy-documentation.yml index f3d5b2f9..d66767c5 100644 --- a/.github/workflows/deploy-documentation.yml +++ b/.github/workflows/deploy-documentation.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Print job info run: | - echo "The job was automatically triggered by a ${{ github.event_name }} event and is now running on a ${{ runner.os }} server." + echo "The job was triggered by a ${{ github.event_name }} event and is now running on a ${{ runner.os }} server." echo "The repository is ${{ github.repository }} and the branch is ${{ github.ref }}." echo "Available XCode versions:" sudo ls -1 /Applications | grep "Xcode" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..b735707b --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,21 @@ +name: "Run tests" +run-name: ${{ github.actor }} is running tests +on: [push] +jobs: + run-tests: + runs-on: macos-14 + steps: + - name: Print job info + run: | + echo "The job was triggered by a ${{ github.event_name }} event and is now running on a ${{ runner.os }} server." + echo "The repository is ${{ github.repository }} and the branch is ${{ github.ref }}." + echo "Available XCode versions:" + sudo ls -1 /Applications | grep "Xcode" + echo "Selected XCode version:" + /usr/bin/xcodebuild -version + + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Run the tests + run: xcodebuild -scheme StreamDeckKit-Package test -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" | xcpretty diff --git a/Example/Example App/StreamDeckHandler.swift b/Example/Example App/StreamDeckHandler.swift index 9470bb01..27581195 100644 --- a/Example/Example App/StreamDeckHandler.swift +++ b/Example/Example App/StreamDeckHandler.swift @@ -97,8 +97,8 @@ class StreamDeckHandler { case let .rotaryEncoderRotation(index, rotation): print("Rotary \(index) rotation \"\(rotation)\" on \(device.infoText).") - case let .touch(x, y): - print("Did touch at (x:\(x), y:\(y)) on \(device.infoText).") + case let .touch(point): + print("Did touch at (\(point.debugDescription)) on \(device.infoText).") case .fling: print("Did fling to \(event.direction) on \(device.infoText).") diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..45824189 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "8e68404f641300bfd0e37d478683bb275926760c", + "version" : "1.15.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 69365f8a..7432b812 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,12 @@ let package = Package( targets: ["StreamDeckSimulator"] ) ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + from: "1.12.0" + ), + ], targets: [ .target( name: "StreamDeckSimulator", @@ -40,7 +46,12 @@ let package = Package( ), .testTarget( name: "StreamDeckSDKTests", - dependencies: ["StreamDeckKit"] + dependencies: [ + "StreamDeckKit", + "StreamDeckLayout", + "StreamDeckSimulator", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing") + ] ) ] ) diff --git a/Sources/StreamDeckKit/AsyncQueue.swift b/Sources/StreamDeckKit/AsyncQueue.swift index 0149f9d0..104ce08a 100644 --- a/Sources/StreamDeckKit/AsyncQueue.swift +++ b/Sources/StreamDeckKit/AsyncQueue.swift @@ -13,6 +13,10 @@ final class AsyncQueue { private var queue = [Element]() private var continuations = [UnsafeContinuation]() + var count: Int { + lock.withLock { queue.count } + } + func enqueue(_ element: Element) { lock.withLock { guard continuations.isEmpty else { diff --git a/Sources/StreamDeckKit/Extensions/StreamDeck+OperationQueue.swift b/Sources/StreamDeckKit/Extensions/StreamDeck+OperationQueue.swift index 21c6ea6e..d4a7a5c4 100644 --- a/Sources/StreamDeckKit/Extensions/StreamDeck+OperationQueue.swift +++ b/Sources/StreamDeckKit/Extensions/StreamDeck+OperationQueue.swift @@ -16,6 +16,7 @@ extension StreamDeck { case setFullscreenImage(image: UIImage, scaleAspectFit: Bool) case setTouchAreaImage(image: UIImage, at: CGRect, scaleAspectFit: Bool) case fillDisplay(color: UIColor) + case task(() async -> Void) case close var isDrawingOperation: Bool { @@ -38,10 +39,12 @@ extension StreamDeck { } func enqueueOperation(_ operation: Operation) { + guard !isClosed else { return } + var wasReplaced = false switch operation { - case .setInputEventHandler, .setBrightness: + case .setInputEventHandler, .setBrightness, .task: break case let .setImageOnKey(_, key, _): @@ -77,10 +80,15 @@ extension StreamDeck { private func run(_ operation: Operation) async { switch operation { case let .setInputEventHandler(handler): - await MainActor.run { client.setInputEventHandler(handler) } + guard !didSetInputEventHandler else { return } + + await MainActor.run { + client.setInputEventHandler(handler) + didSetInputEventHandler = true + } case let .setBrightness(brightness): - client.setBrightness(brightness) + client.setBrightness(min(max(brightness, 0), 100)) case let .setImageOnKey(image, key, scaleAspectFit): guard let keySize = capabilities.keySize, @@ -114,19 +122,30 @@ extension StreamDeck { var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - client.fillDisplay(red: UInt8(255 * red), green: UInt8(255 * green), blue: UInt8(255 * blue)) + + client.fillDisplay( + red: UInt8(min(255 * red, 255)), + green: UInt8(min(255 * green, 255)), + blue: UInt8(min(255 * blue, 255)) + ) } else { fakeFillDisplay(color) } - case .close: - client.close() + case let .task(task): + await task() + case .close: for handler in closeHandlers { await handler() } + client.close() + isClosed = true + + operationsQueue.removeAll() operationsTask?.cancel() } } diff --git a/Sources/StreamDeckKit/Models/InputEvent.swift b/Sources/StreamDeckKit/Models/InputEvent.swift index b74cb95f..97e3cced 100644 --- a/Sources/StreamDeckKit/Models/InputEvent.swift +++ b/Sources/StreamDeckKit/Models/InputEvent.swift @@ -39,33 +39,28 @@ public enum InputEvent: Equatable { case rotaryEncoderRotation(index: Int, rotation: Int) /// Signals a touch on the touch strip of e.g. a Stream Deck Plus. - /// - Parameters: - /// - x: The horizontal position of the touch event. - /// - y: The vertical position of the touch event. - case touch(x: Int, y: Int) + case touch(CGPoint) /// Signals a swipe-like gesture on the touch strip of e.g. a Stream Deck Plus. /// - Parameters: - /// - startX: The horizontal start position of the fling. - /// - startY: The vertical start position of the fling. - /// - endX: The horizontal end position of the fling. - /// - endY: The vertical end position of the fling. + /// - start: The start position of the fling. + /// - end: The end position of the fling. /// /// The intensity of the gesture can be calculated by getting the distance between start and end-point. - case fling(startX: Int, startY: Int, endX: Int, endY: Int) + case fling(start: CGPoint, end: CGPoint) /// The direction of a ``fling(startX:startY:endX:endY:)`` event. /// /// When the event is anything but a fling, ``Direction-swift.enum/none`` will be returned. public var direction: Direction { switch self { - case let .fling(startX, startY, endX, endY): - guard startX != endX || startY != endY else { + case let .fling(start, end): + guard start != end else { return .none } - let diffX = startX - endX - let diffY = startY - endY + let diffX = start.x - end.x + let diffY = start.y - end.y if abs(diffX) > abs(diffY) { return diffX < 0 ? .right : .left @@ -89,10 +84,10 @@ extension InputEvent: CustomStringConvertible { return "InputEvent.rotaryEncoderPress(index: \(index), pressed: \(pressed))" case let .rotaryEncoderRotation(index, rotation): return "InputEvent.rotaryEncoderRotation(index: \(index), rotation: \(rotation))" - case let .touch(x, y): - return "InputEvent.touch(x: \(x), y: \(y))" - case let .fling(startX, startY, endX, endY): - return "InputEvent.fling(startX: \(startX), startY: \(startY), endX: \(endX), endY: \(endY))" + case let .touch(point): + return "InputEvent.touch(x: \(point.x), y: \(point.y))" + case let .fling(start, end): + return "InputEvent.fling(start: \(start.x),\(start.y), end: \(end.x),\(end.y))" } } diff --git a/Sources/StreamDeckKit/Models/StreamDeck.swift b/Sources/StreamDeckKit/Models/StreamDeck.swift index 6e2fbecb..f07754d4 100644 --- a/Sources/StreamDeckKit/Models/StreamDeck.swift +++ b/Sources/StreamDeckKit/Models/StreamDeck.swift @@ -20,9 +20,12 @@ public final class StreamDeck { public let info: DeviceInfo /// Capabilities and features of the device. public let capabilities: DeviceCapabilities + /// Check if this device was closed. If true, all operations are silently ignored. + public internal(set) var isClosed: Bool = false let operationsQueue = AsyncQueue() var operationsTask: Task? + var didSetInputEventHandler = false private let inputEventsSubject = PassthroughSubject() @@ -42,9 +45,9 @@ public final class StreamDeck { /// Set a handler to handle key-presses, touches and other events. public var inputEventHander: InputEventHandler? { didSet { - if inputEventHander != nil { - subscribeToInputEvents() - } + guard inputEventHander != nil else { return } + + subscribeToInputEvents() } } @@ -76,6 +79,8 @@ public final class StreamDeck { } private func subscribeToInputEvents() { + guard !didSetInputEventHandler else { return } + enqueueOperation(.setInputEventHandler { [weak self] event in self?.handleInputEvent(event) }) @@ -143,7 +148,12 @@ public final class StreamDeck { /// the image will be scaled to fill the whole `rect`. /// /// The image will be scaled to fit the dimensions of the given rectangle. - public func setTouchAreaImage(_ image: UIImage, at rect: CGRect, scaleAspectFit: Bool = true) { + public func setTouchAreaImage(_ image: UIImage, at rect: CGRect? = nil, scaleAspectFit: Bool = true) { + guard capabilities.hasSetImageOnXYSupport, + let touchDisplayRect = capabilities.touchDisplayRect + else { return } + + let rect = rect ?? .init(origin: .zero, size: touchDisplayRect.size) enqueueOperation(.setTouchAreaImage(image: image, at: rect, scaleAspectFit: scaleAspectFit)) } diff --git a/Sources/StreamDeckKit/StreamDeckClient.swift b/Sources/StreamDeckKit/StreamDeckClient.swift index 5f5f4e3e..f2c381f9 100644 --- a/Sources/StreamDeckKit/StreamDeckClient.swift +++ b/Sources/StreamDeckKit/StreamDeckClient.swift @@ -71,11 +71,17 @@ final class StreamDeckClient: StreamDeckClientProtocol { } case SDInputEventTypeTouch.rawValue: - inputEventHandler?(.touch(x: Int(event.touch.x), y: Int(event.touch.y))) + inputEventHandler?(.touch(.init( + x: Int(event.touch.x), + y: Int(event.touch.y)) + )) case SDInputEventTypeFling.rawValue: let fling = event.fling - inputEventHandler?(.fling(startX: Int(fling.startX), startY: Int(fling.startY), endX: Int(fling.endX), endY: Int(fling.endY))) + inputEventHandler?(.fling( + start: .init(x: Int(fling.startX), y: Int(fling.startY)), + end: .init(x: Int(fling.endX), y: Int(fling.endY)) + )) default: return } diff --git a/Sources/StreamDeckLayout/Environment+Ext.swift b/Sources/StreamDeckLayout/Environment+Ext.swift index 3fd09086..e84a4d30 100644 --- a/Sources/StreamDeckLayout/Environment+Ext.swift +++ b/Sources/StreamDeckLayout/Environment+Ext.swift @@ -11,7 +11,11 @@ import SwiftUI public struct StreamDeckViewContextKey: EnvironmentKey { public static var defaultValue: StreamDeckViewContext = .init( - device: StreamDeck(client: StreamDeckClientMock(), info: .init(), capabilities: .init()), + device: StreamDeck( + client: StreamDeckClientDummy(), + info: .init(), + capabilities: .init() + ), dirtyMarker: .background, size: .zero ) diff --git a/Sources/StreamDeckKit/Models/StreamDeckClientMock.swift b/Sources/StreamDeckLayout/StreamDeckClientDummy.swift similarity index 81% rename from Sources/StreamDeckKit/Models/StreamDeckClientMock.swift rename to Sources/StreamDeckLayout/StreamDeckClientDummy.swift index 107f9e0d..7cc7f4da 100644 --- a/Sources/StreamDeckKit/Models/StreamDeckClientMock.swift +++ b/Sources/StreamDeckLayout/StreamDeckClientDummy.swift @@ -1,15 +1,15 @@ // -// File.swift -// +// StreamDeckClientDummy.swift +// // // Created by Roman Schlagowsky on 05.01.24. // import Combine import Foundation -import StreamDeckCApi +import StreamDeckKit -public final class StreamDeckClientMock: StreamDeckClientProtocol { +final class StreamDeckClientDummy: StreamDeckClientProtocol { public init() {} public func setInputEventHandler(_ handler: @escaping InputEventHandler) {} public func setBrightness(_ brightness: Int) {} diff --git a/Sources/StreamDeckLayout/Views/StreamDeckDialLayout.swift b/Sources/StreamDeckLayout/Views/StreamDeckDialLayout.swift deleted file mode 100644 index a69de4bb..00000000 --- a/Sources/StreamDeckLayout/Views/StreamDeckDialLayout.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StreamDeckDialLayout.swift -// StreamDeckDriverTest -// -// Created by Alexander Jentz on 27.11.23. -// - -import SwiftUI - -public struct StreamDeckDialLayout: View { - @Environment(\.streamDeckViewContext) private var context - - @ViewBuilder let dial: @MainActor (StreamDeckViewContext) -> Dial - - let touch: @MainActor (CGPoint) -> Void - let fling: @MainActor (CGPoint, CGPoint) -> Void - - public init( - touch: @escaping (CGPoint) -> Void = { _ in }, - fling: @escaping (CGPoint, CGPoint) -> Void = { _, _ in }, - @ViewBuilder dial: @escaping @MainActor (StreamDeckViewContext) -> Dial - ) { - self.touch = touch - self.fling = fling - self.dial = dial - } - - public var body: some View { - let caps = context.device.capabilities - - HStack(spacing: 0) { - ForEach(0 ..< caps.dialCount, id: \.self) { section in - let dialContext = context.with( - dirtyMarker: .touchAreaSection(section), - size: .init(width: context.size.width / CGFloat(caps.dialCount), height: context.size.height), - index: section - ) - dial(dialContext) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .environment(\.streamDeckViewContext, dialContext) - } - }.onReceive(context.device.inputEventsPublisher) { event in - switch event { - case let .touch(x, y): - touch(.init(x: x, y: y)) - case let .fling(startX, startY, endX, endY): - fling(.init(x: startX, y: startY), .init(x: endX, y: endY)) - default: break - } - } - } -} diff --git a/Sources/StreamDeckLayout/Views/StreamDeckDialView.swift b/Sources/StreamDeckLayout/Views/StreamDeckDialView.swift index a2586508..90f74c8e 100644 --- a/Sources/StreamDeckLayout/Views/StreamDeckDialView.swift +++ b/Sources/StreamDeckLayout/Views/StreamDeckDialView.swift @@ -9,26 +9,33 @@ import Foundation import SwiftUI public struct StreamDeckDialView: View { + public typealias DialRotationHandler = @MainActor (Int) -> Void + public typealias DialPressHandler = @MainActor (Bool) -> Void + public typealias TouchHandler = @MainActor (CGPoint) -> Void + @Environment(\.streamDeckViewContext) private var context - let rotate: @MainActor (Int) -> Void - let press: @MainActor (Bool) -> Void - - @ViewBuilder let content: @MainActor () -> Content + private let rotate: DialRotationHandler? + private let press: DialPressHandler? + private let touch: TouchHandler? + @ViewBuilder private let content: @MainActor () -> Content public init( - rotate: @escaping @MainActor (Int) -> Void, - press: @escaping @MainActor (Bool) -> Void, + rotate: DialRotationHandler? = nil, + press: DialPressHandler? = nil, + touch: TouchHandler? = nil, @ViewBuilder content: @escaping @MainActor () -> Content ) { self.rotate = rotate self.press = press + self.touch = touch self.content = content } public init( - rotate: @escaping @MainActor (Int) -> Void = { _ in }, - press: @escaping @MainActor () -> Void = {}, + rotate: DialRotationHandler? = nil, + press: @escaping @MainActor () -> Void, + touch: TouchHandler? = nil, @ViewBuilder content: @escaping @MainActor () -> Content ) { self.init( @@ -44,11 +51,22 @@ public struct StreamDeckDialView: View { switch event { case let .rotaryEncoderPress(index, pressed): if index == context.index { - press(pressed) + press?(pressed) } case let .rotaryEncoderRotation(index, rotation): if index == context.index { - rotate(rotation) + rotate?(rotation) + } + case let .touch(point): + guard let handler = touch else { return } + let caps = context.device.capabilities + let rect = caps.getTouchAreaSectionDeviceRect(context.index) + if rect.contains(point) { + let relativ = CGPoint( + x: point.x - rect.origin.x, + y: point.y - rect.origin.y + ) + handler(relativ) } default: break } diff --git a/Sources/StreamDeckLayout/Views/StreamDeckTouchAreaLayout.swift b/Sources/StreamDeckLayout/Views/StreamDeckTouchAreaLayout.swift new file mode 100644 index 00000000..a5bb0f4a --- /dev/null +++ b/Sources/StreamDeckLayout/Views/StreamDeckTouchAreaLayout.swift @@ -0,0 +1,84 @@ +// +// StreamDeckTouchAreaLayout.swift +// StreamDeckDriverTest +// +// Created by Alexander Jentz on 27.11.23. +// + +import StreamDeckKit +import SwiftUI + +public struct StreamDeckTouchAreaLayout: View { + public typealias DialRotationHandler = @MainActor (Int, Int) -> Void + public typealias DialPressHandler = @MainActor (Int, Bool) -> Void + public typealias TouchHandler = @MainActor (CGPoint) -> Void + public typealias FlingHandler = @MainActor (CGPoint, CGPoint, InputEvent.Direction) -> Void + + @Environment(\.streamDeckViewContext) private var context + + private let rotate: DialRotationHandler? + private let press: DialPressHandler? + private let touch: TouchHandler? + private let fling: FlingHandler? + @ViewBuilder private let dial: @MainActor (StreamDeckViewContext) -> Dial + + public init( + rotate: DialRotationHandler? = nil, + press: DialPressHandler? = nil, + touch: TouchHandler? = nil, + fling: FlingHandler? = nil, + @ViewBuilder dial: @escaping @MainActor (StreamDeckViewContext) -> Dial + ) { + self.rotate = rotate + self.press = press + self.touch = touch + self.fling = fling + self.dial = dial + } + + public init( + rotate: DialRotationHandler? = nil, + press: @escaping @MainActor (Int) -> Void, + touch: TouchHandler? = nil, + fling: FlingHandler? = nil, + @ViewBuilder dial: @escaping @MainActor (StreamDeckViewContext) -> Dial + ) { + self.init( + rotate: rotate, + press: { if $1 { press($0) } }, + touch: touch, + fling: fling, + dial: dial + ) + } + + public var body: some View { + let caps = context.device.capabilities + + HStack(spacing: 0) { + ForEach(0 ..< caps.dialCount, id: \.self) { section in + let dialContext = context.with( + dirtyMarker: .touchAreaSection(section), + size: .init(width: context.size.width / CGFloat(caps.dialCount), height: context.size.height), + index: section + ) + dial(dialContext) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .environment(\.streamDeckViewContext, dialContext) + } + } + .onReceive(context.device.inputEventsPublisher) { event in + switch event { + case let .rotaryEncoderRotation(index, steps): + rotate?(index, steps) + case let .rotaryEncoderPress(index, pressed): + press?(index, pressed) + case let .touch(point): + touch?(point) + case let .fling(start, end): + fling?(start, end, event.direction) + default: break + } + } + } +} diff --git a/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift b/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift index ac68967a..0e15b376 100644 --- a/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift +++ b/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift @@ -168,19 +168,17 @@ private extension StreamDeckSimulatorView { } } } touchAreaView: { context in - StreamDeckDialLayout { context in + StreamDeckTouchAreaLayout { context in StreamDeckDialView { SimulatorTouchView { localLocation in let x = CGFloat(context.index) * context.size.width + localLocation.x - client.emit(.touch(x: Int(x), y: Int(localLocation.y))) + client.emit(.touch(.init(x: x, y: localLocation.y))) } onFling: { startLocation, endLocation in let startX = CGFloat(context.index) * context.size.width + startLocation.x let endX = CGFloat(context.index) * context.size.width + endLocation.x client.emit(.fling( - startX: Int(startX), - startY: Int(startLocation.y), - endX: Int(endX), - endY: Int(endLocation.y) + start: .init(x: startX, y: startLocation.y), + end: .init(x: endX, y: endLocation.y) )) } } @@ -213,7 +211,7 @@ private extension StreamDeckSimulatorView { if device.capabilities.dialCount != 0 { Spacer() - StreamDeckDialLayout { _ in + StreamDeckTouchAreaLayout { _ in StreamDeckDialView { SimulatorTouchView { _ in } onFling: { _, _ in } .border(.red) diff --git a/Tests/StreamDeckSDKTests/Helper/StreamDeckClientMock.swift b/Tests/StreamDeckSDKTests/Helper/StreamDeckClientMock.swift new file mode 100644 index 00000000..b7279e80 --- /dev/null +++ b/Tests/StreamDeckSDKTests/Helper/StreamDeckClientMock.swift @@ -0,0 +1,158 @@ +// +// StreamDeckClientMock.swift +// +// +// Created by Alexander Jentz on 30.01.24. +// + +import Combine +import StreamDeckKit +import UIKit + +public final class StreamDeckClientMock { + public typealias Key = (index: Int, image: UIImage) + public typealias TouchAreaImage = (rect: CGRect, image: UIImage) + public typealias Color = (red: UInt8, green: UInt8, blue: UInt8) + + final class Recorder { + @Published public var brightnesses = [Int]() + @Published public var keys = [Key]() + @Published public var fullscreens = [UIImage]() + @Published public var fillDisplays = [Color]() + @Published public var touchAreaImages = [TouchAreaImage]() + + private var cancellables = [AnyCancellable]() + + fileprivate init(mock: StreamDeckClientMock) { + mock.brightness + .sink { [weak self] brightness in + self?.brightnesses.append(brightness) + } + .store(in: &cancellables) + + mock.keys + .sink { [weak self] key in + self?.keys.append(key) + } + .store(in: &cancellables) + + mock.fullscreen + .sink { [weak self] image in + self?.fullscreens.append(image) + } + .store(in: &cancellables) + + mock.fillDisplay + .sink { [weak self] color in + self?.fillDisplays.append(color) + } + .store(in: &cancellables) + + mock.touchArea + .sink { [weak self] image in + self?.touchAreaImages.append(image) + } + .store(in: &cancellables) + } + } + + private var inputEventHandler: InputEventHandler? + private let subscribedToInputEventsSubject = CurrentValueSubject(false) + private let brightnessSubject = PassthroughSubject() + private let keysSubject = PassthroughSubject() + private let touchAreaSubject = PassthroughSubject() + private let fullscreenSubject = PassthroughSubject() + private let fillDisplaySubject = PassthroughSubject() + private let closedSubject = CurrentValueSubject(false) + private let lock = NSLock() + + public var isBusy: Bool = false { + didSet { + if isBusy, !oldValue { + lock.lock() + } else if !isBusy, oldValue { + lock.unlock() + } + } + } + + public var subscribedToInputEvents: AnyPublisher { + subscribedToInputEventsSubject.eraseToAnyPublisher() + } + + public var brightness: AnyPublisher { + brightnessSubject.eraseToAnyPublisher() + } + + public var keys: AnyPublisher { + keysSubject.eraseToAnyPublisher() + } + + public var fullscreen: AnyPublisher { + fullscreenSubject.eraseToAnyPublisher() + } + + public var touchArea: AnyPublisher { + touchAreaSubject.eraseToAnyPublisher() + } + + public var fillDisplay: AnyPublisher { + fillDisplaySubject.eraseToAnyPublisher() + } + + public var isClosed: Bool { closedSubject.value } + + func record() -> Recorder { + Recorder(mock: self) + } + + @MainActor + public func emit(_ event: InputEvent) { + self.inputEventHandler?(event) + } +} + +extension StreamDeckClientMock: StreamDeckClientProtocol { + public func setInputEventHandler(_ handler: @escaping InputEventHandler) { + lock.lock(); defer { lock.unlock() } + inputEventHandler = handler + subscribedToInputEventsSubject.send(true) + } + + public func setBrightness(_ brightness: Int) { + lock.lock(); defer { lock.unlock() } + brightnessSubject.send(brightness) + } + + public func setImage(_ data: Data, toButtonAt index: Int) { + lock.lock(); defer { lock.unlock() } + guard let image = UIImage(data: data, scale: 1) else { return } + keysSubject.send((index: index, image: image)) + } + + public func setImage(_ data: Data, x: Int, y: Int, w: Int, h: Int) { + lock.lock(); defer { lock.unlock() } + guard let image = UIImage(data: data, scale: 1) else { return } + touchAreaSubject.send(( + rect: .init(x: x, y: y, width: w, height: h), + image: image + )) + } + + public func setFullscreenImage(_ data: Data) { + lock.lock(); defer { lock.unlock() } + guard let image = UIImage(data: data, scale: 1) else { return } + fullscreenSubject.send(image) + } + + public func fillDisplay(red: UInt8, green: UInt8, blue: UInt8) { + lock.lock(); defer { lock.unlock() } + fillDisplaySubject.send((red: red, green: green, blue: blue)) + } + + public func close() { + lock.lock(); defer { lock.unlock() } + closedSubject.send(true) + } + +} diff --git a/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift b/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift new file mode 100644 index 00000000..a24603d8 --- /dev/null +++ b/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift @@ -0,0 +1,228 @@ +// +// StreamDeckRobot.swift +// +// +// Created by Alexander Jentz on 01.02.24. +// + +import Combine +import SnapshotTesting +@testable import StreamDeckKit +import StreamDeckLayout +@testable import StreamDeckSimulator +import SwiftUI +import UIKit +import XCTest + +final class StreamDeckRobot { + private let renderer = StreamDeckLayoutRenderer() + + var device: StreamDeck! + var client: StreamDeckClientMock! + var recorder: StreamDeckClientMock.Recorder! + + func tearDown() { + device.close() + device = nil + client = nil + recorder = nil + } + + func use(_ product: StreamDeckProduct) { + device?.close() + client = StreamDeckClientMock() + device = StreamDeck( + client: client, + info: .init(), + capabilities: product.capabilities + ) + recorder = client.record() + } + + func use( + _ product: StreamDeckProduct, + rendering content: Content, + file: StaticString = #file, + line: UInt = #line + ) async { + use(product) + + await renderer.render(content, on: device) + + do { + try await recorder.$fullscreens.waitFor { !$0.isEmpty } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + func operateDevice(isBusy: Bool = false, block: (StreamDeck) -> Void) async { + client.isBusy = isBusy + block(device) + client.isBusy = false + await digest() + } + + func digest() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + device.enqueueOperation(.task { continuation.resume() }) + } + } + + // MARK: Assertions + + func assertSnapshot( + _ path: KeyPath, + as format: Snapshotting, + named name: String? = nil, + record recording: Bool = false, + timeout: TimeInterval = 5, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) async { + await MainActor.run { + SnapshotTesting.assertSnapshot( + of: self.recorder[keyPath: path], + as: format, + named: name, + record: recording, + timeout: timeout, + file: file, + testName: testName, + line: line + ) + } + } + + func assertEqual( + _ path: KeyPath, + _ expectation: @autoclosure () throws -> Value, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line + ) rethrows { + XCTAssertEqual(recorder[keyPath: path], try expectation(), message(), file: file, line: line) + } + + // MARK: Events + + private func emit(_ event: InputEvent) async throws { + try await client.subscribedToInputEvents.waitFor(description: "Ready for inputs") { $0 } + await client.emit(event) + } + + func keyPress( + _ index: Int, + pressed: Bool, + waitForLayout: Bool = true, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let keysCount = recorder.keys.count + + do { + try await emit(.keyPress(index: index, pressed: pressed)) + + if waitForLayout { + try await recorder.$keys.waitFor(description: "key press was rendered") { + $0.count == keysCount + 1 && $0.last?.index == index + } + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + func rotate( + _ index: Int, + steps: Int, + waitForLayout: Bool = true, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let imageCount = recorder.touchAreaImages.count + + do { + try await emit(.rotaryEncoderRotation(index: index, rotation: steps)) + + if waitForLayout { + try await recorder.$touchAreaImages.waitFor(description: "touch area was rendered") { + $0.count == imageCount + 1 + } + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + func rotaryEncoderPress( + _ index: Int, + pressed: Bool, + waitForLayout: Bool = true, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let imageCount = recorder.touchAreaImages.count + + do { + try await emit(.rotaryEncoderPress(index: index, pressed: pressed)) + + if waitForLayout { + try await recorder.$touchAreaImages.waitFor(description: "touch area was rendered") { + $0.count == imageCount + 1 + } + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + func fling( + startX: Int, + startY: Int, + endX: Int, + endY: Int, + waitForLayout: Bool = true, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let imageCount = recorder.touchAreaImages.count + + do { + try await emit(.fling(start: .init(x: startX, y: startY), end: .init(x: endX, y: endY))) + + if waitForLayout { + try await recorder.$touchAreaImages.waitFor(description: "touch area was rendered") { + $0.count == imageCount + 1 + } + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + func touch( + x: Int, + y: Int, + waitForLayout: Bool = true, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let imageCount = recorder.touchAreaImages.count + + do { + try await emit(.touch(.init(x: x, y: y))) + + if waitForLayout { + try await recorder.$touchAreaImages.waitFor(description: "touch area was rendered") { + $0.count == imageCount + 1 + } + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + } + + +} diff --git a/Tests/StreamDeckSDKTests/Helper/TestViews.swift b/Tests/StreamDeckSDKTests/Helper/TestViews.swift new file mode 100644 index 00000000..379beb54 --- /dev/null +++ b/Tests/StreamDeckSDKTests/Helper/TestViews.swift @@ -0,0 +1,150 @@ +// +// TestViews.swift +// +// +// Created by Alexander Jentz on 01.02.24. +// + +import StreamDeckKit +import StreamDeckLayout +import SwiftUI + +enum TestViews { + + final class SimpleEventModel: ObservableObject { + enum Event: Equatable, CustomStringConvertible { + case none, press(Bool), rotate(Int), fling(InputEvent.Direction), touch(CGPoint) + + var description: String { + switch self { + case .none: "none" + case let .press(pressed): pressed ? "pressed" : "released" + case let .rotate(steps): "steps \(steps)" + case let .fling(direction): "fling \(direction.description)" + case let .touch(point): "touch(\(point.x),\(point.y))" + } + } + + } + + @Published var lastEvent: Event = .none + } + + struct SimpleKey: View { + @StateObject var model = SimpleEventModel() + @Environment(\.streamDeckViewContext) var context + + var body: some View { + StreamDeckKeyView { isPressed in + model.lastEvent = .press(isPressed) + } content: { + ZStack { + Rectangle() + .fill(model.lastEvent == .press(true) ? .red : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack { + Text("Key \(context.index)") + Text("\(model.lastEvent.description)") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onChange(of: model.lastEvent) { _, _ in + context.updateRequired() + } + } + } + + struct SimpleDialView: View { + @StateObject var model = SimpleEventModel() + @Environment(\.streamDeckViewContext) var context + + var body: some View { + StreamDeckDialView { steps in + model.lastEvent = .rotate(steps) + } press: { pressed in + model.lastEvent = .press(pressed) + } touch: { point in + model.lastEvent = .touch(point) + } content: { + VStack { + Text("Dial \(context.index)") + Text(model.lastEvent.description) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + } + .onChange(of: model.lastEvent) { _, _ in + context.updateRequired() + } + } + } + + struct SimpleLayout: View { + @Environment(\.streamDeckViewContext) var context + + var body: some View { + StreamDeckLayout( + background: { _ in EmptyView() }, + keyAreaView: { _ in + StreamDeckKeypadLayout { _ in + SimpleKey() + } + }) { context in + StreamDeckTouchAreaLayout { _ in + SimpleDialView() + } + } + } + } + + struct TouchAreaTestLayout: View { + @StateObject var model = SimpleEventModel() + @Environment(\.streamDeckViewContext) var context + + var body: some View { + StreamDeckLayout( + background: { _ in EmptyView() }, + keyAreaView: { _ in + StreamDeckKeypadLayout { _ in SimpleKey() } + } + ) { context in + ZStack { + StreamDeckTouchAreaLayout( + rotate: { _, steps in + model.lastEvent = .rotate(steps) + }, + press: { _, isPressed in + model.lastEvent = .press(isPressed) + }, + touch: { point in + model.lastEvent = .touch(point) + }, + fling: { _, _, direction in + model.lastEvent = .fling(direction) + } + ) { _ in SimpleDialView() } + + Text(model.lastEvent.description) + } + .onChange(of: model.lastEvent) { _, _ in + context.updateRequired() + } + } + } + } + +} + +extension InputEvent.Direction: CustomStringConvertible { + public var description: String { + switch self { + case .left: "left" + case .up: "up" + case .right: "right" + case .down: "down" + case .none: "none" + } + } +} diff --git a/Tests/StreamDeckSDKTests/Helper/WaitFor.swift b/Tests/StreamDeckSDKTests/Helper/WaitFor.swift new file mode 100644 index 00000000..a98e59d5 --- /dev/null +++ b/Tests/StreamDeckSDKTests/Helper/WaitFor.swift @@ -0,0 +1,95 @@ +// +// WaitFor.swift +// +// +// Created by Alexander Jentz on 31.01.24. +// + +import Combine +import XCTest + +enum WaitForError: Error { + case timeout(description: String?, timeout: TimeInterval, lastOutput: Any?) + case publisherError(description: String?, cause: Error, lastOutput: Any?) + case completedWithoutResult(description: String?, lastOutput: Any?) +} + +extension WaitForError: LocalizedError { + + var errorDescription: String? { + func info(_ value: Any?) -> String { + value.flatMap { "(last output: \(String(reflecting: $0)))" } ?? "(no last output or nil)" + } + + switch self { + case let .timeout(description, timeout, lastOutput): + if let description = description, !description.isEmpty { + return "waitFor `\(description)` timed out after \(timeout) seconds \(info(lastOutput))." + } else { + return "waitFor publisher timed out after \(timeout) seconds \(info(lastOutput))." + } + case let .publisherError(description, cause, lastOutput): + if let description = description, !description.isEmpty { + return "waitFor `\(description)` failed with error: \(String(reflecting: cause)) \(info(lastOutput))." + } else { + return "waitFor publisher failed with error: \(String(reflecting: cause)) \(info(lastOutput))." + } + case let .completedWithoutResult(description, lastOutput): + if let description = description, !description.isEmpty { + return "waitFor `\(description)` completed without result \(info(lastOutput))." + } else { + return "waitFor publisher completed without result \(info(lastOutput))" + } + } + } + +} + +extension Publisher { + + @discardableResult + func waitFor( + timeout: TimeInterval = 5.0, + description: String? = nil, + file: StaticString = #filePath, + line: UInt = #line, + condition: @escaping (Self.Output) -> Bool = { _ -> Bool in true } + ) async throws -> Output { + var lastOutput: Output? + + let sequence = handleEvents(receiveOutput: { lastOutput = $0 }) + .filter(condition) + .mapError { + WaitForError.publisherError( + description: description, + cause: $0, + lastOutput: lastOutput + ) + } + .timeout( + .milliseconds(Int(timeout * 1000)), + scheduler: RunLoop.main, + customError: { + WaitForError.timeout( + description: description, + timeout: timeout, + lastOutput: lastOutput + ) + } + ) + .values + + do { + for try await value in sequence { + return value + } + } catch let error as WaitForError { + XCTFail(error.errorDescription ?? "no description", file: file, line: line) + throw error + } + + let error = WaitForError.completedWithoutResult(description: description, lastOutput: lastOutput) + XCTFail(error.errorDescription ?? "no description", file: file, line: line) + throw error + } +} diff --git a/Tests/StreamDeckSDKTests/StreamDeckLayoutTests.swift b/Tests/StreamDeckSDKTests/StreamDeckLayoutTests.swift new file mode 100644 index 00000000..1a1bf206 --- /dev/null +++ b/Tests/StreamDeckSDKTests/StreamDeckLayoutTests.swift @@ -0,0 +1,119 @@ +// +// StreamDeckLayoutTests.swift +// +// +// Created by Alexander Jentz on 01.02.24. +// + +import SwiftUI +import XCTest +import SnapshotTesting +@testable import StreamDeckKit +@testable import StreamDeckLayout +@testable import StreamDeckSimulator + +final class StreamDeckLayoutTests: XCTestCase { + + private var robot = StreamDeckRobot() + + override func tearDown() { + robot.tearDown() + } + + // MARK: Initial rendering + + func test_render_initial_frame() async throws { + await robot.use(.regular, rendering: TestViews.SimpleLayout()) + await robot.assertSnapshot(\.fullscreens[0], as: .image) + } + + // MARK: Key handling + + func test_key_down_up() async throws { + await robot.use(.regular, rendering: TestViews.SimpleLayout()) + + await robot.keyPress(1, pressed: true) + await robot.keyPress(1, pressed: false) + + robot.assertEqual(\.fullscreens.count, 1) + robot.assertEqual(\.keys.count, 2) + + + await robot.assertSnapshot(\.fullscreens[0], as: .image, named: "fullscreen") + await robot.assertSnapshot(\.keys[0].image, as: .image, named: "key_down") + await robot.assertSnapshot(\.keys[1].image, as: .image, named: "key_up") + } + + // MARK: Dial handling + + func test_dial_rotate_and_click_on_dial_view() async throws { + await robot.use(.plus, rendering: TestViews.SimpleLayout()) + + await robot.rotate(2, steps: 3) + await robot.rotate(2, steps: -3) + + await robot.rotaryEncoderPress(3, pressed: true) + await robot.rotaryEncoderPress(3, pressed: false) + + await robot.assertSnapshot(\.touchAreaImages[0].image, as: .image, named: "dial_right") + await robot.assertSnapshot(\.touchAreaImages[1].image, as: .image, named: "dial_left") + await robot.assertSnapshot(\.touchAreaImages[2].image, as: .image, named: "encoder_down") + await robot.assertSnapshot(\.touchAreaImages[3].image, as: .image, named: "encoder_up") + } + + func test_dial_rotate_and_click_on_touch_area() async throws { + await robot.use(.plus, rendering: TestViews.TouchAreaTestLayout()) + + await robot.rotate(2, steps: 3) + await robot.rotate(2, steps: -3) + + await robot.rotaryEncoderPress(3, pressed: true) + await robot.rotaryEncoderPress(3, pressed: false) + + await robot.assertSnapshot(\.touchAreaImages[0].image, as: .image, named: "dial_right") + await robot.assertSnapshot(\.touchAreaImages[1].image, as: .image, named: "dial_left") + await robot.assertSnapshot(\.touchAreaImages[2].image, as: .image, named: "encoder_down") + await robot.assertSnapshot(\.touchAreaImages[3].image, as: .image, named: "encoder_up") + } + + // MARK: Fling + + func test_fling_on_touch_area() async throws { + await robot.use(.plus, rendering: TestViews.TouchAreaTestLayout()) + + await robot.fling(startX: 30, startY: 5, endX: 5, endY: 6) // left + await robot.fling(startX: 5, startY: 5, endX: 30, endY: 6) // right + await robot.fling(startX: 5, startY: 5, endX: 8, endY: 80) // down + await robot.fling(startX: 5, startY: 80, endX: 8, endY: 2) // up + + await robot.assertSnapshot(\.touchAreaImages[0].image, as: .image, named: "fling_left") + await robot.assertSnapshot(\.touchAreaImages[1].image, as: .image, named: "fling_right") + await robot.assertSnapshot(\.touchAreaImages[2].image, as: .image, named: "fling_down") + await robot.assertSnapshot(\.touchAreaImages[3].image, as: .image, named: "fling_up") + } + + // MARK: Touch + + func test_touch_on_touch_area() async throws { + await robot.use(.plus, rendering: TestViews.TouchAreaTestLayout()) + + await robot.touch(x: 30, y: 20) + await robot.touch(x: 80, y: 10) + + await robot.assertSnapshot(\.touchAreaImages[0].image, as: .image, named: "30_20") + await robot.assertSnapshot(\.touchAreaImages[1].image, as: .image, named: "80_10") + } + + func test_touch_on_dial_section() async throws { + await robot.use(.plus, rendering: TestViews.SimpleLayout()) + + let caps = robot.device.capabilities + + for section in 0 ..< caps.dialCount { + let rect = caps.getTouchAreaSectionDeviceRect(section) + + await robot.touch(x: Int(rect.midX), y: Int(rect.midY)) + await robot.assertSnapshot(\.touchAreaImages[section].image, as: .image, named: "section_\(section)") + } + } +} diff --git a/Tests/StreamDeckSDKTests/StreamDeckSDKTests.swift b/Tests/StreamDeckSDKTests/StreamDeckSDKTests.swift deleted file mode 100644 index fbefa99c..00000000 --- a/Tests/StreamDeckSDKTests/StreamDeckSDKTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import XCTest -@testable import StreamDeckKit - -final class StreamDeckSDKTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/Tests/StreamDeckSDKTests/StreamDeckTests.swift b/Tests/StreamDeckSDKTests/StreamDeckTests.swift new file mode 100644 index 00000000..f8ab18d5 --- /dev/null +++ b/Tests/StreamDeckSDKTests/StreamDeckTests.swift @@ -0,0 +1,176 @@ +// +// StreamDeckTests.swift +// +// +// Created by Alexander Jentz on 30.01.24. +// + +import Combine +import SnapshotTesting +import XCTest + +@testable import StreamDeckKit +@testable import StreamDeckSimulator + +final class StreamDeckTests: XCTestCase { + + private let robot = StreamDeckRobot() + + override func setUp() { + robot.use(.regular) + } + + override func tearDown() { + robot.tearDown() + } + + // MARK: Set brightness + + func test_set_brightness_should_set_brightness_on_client() async throws { + await robot.operateDevice { $0.setBrightness(42) } + + robot.assertEqual(\.brightnesses.first, 42) + } + + func test_set_brightness_should_clamp_value_to_be_in_valid_range() async throws { + await robot.operateDevice { device in + device.setBrightness(-20) + device.setBrightness(110) + } + + robot.assertEqual(\.brightnesses, [0, 100]) + } + + // MARK: Set image on key + + func test_set_image_on_key_should_set_scaled_image_on_key() async throws { + await robot.operateDevice { $0.setImage(.colored(.blue)!, to: 2, scaleAspectFit: false) } + + await robot.assertSnapshot(\.keys.first!.image, as: .image) + } + + func test_set_image_on_key_should_replace_pending_operations_for_the_same_key() async throws { + await robot.operateDevice(isBusy: true) { device in + let image: UIImage = .colored(.blue)! + + for _ in 0 ..< 10 { + device.setImage(image, to: 0) + device.setImage(image, to: 1) + } + } + + XCTAssertEqual(robot.recorder.keys.filter { $0.index == 0 }.count, 2) + XCTAssertEqual(robot.recorder.keys.filter { $0.index == 1 }.count, 1) + } + + + // MARK: Fill display + + func test_fill_display_on_device_with_hardware_support() async throws { + await robot.operateDevice { + $0.fillDisplay(.init(red: 0.5, green: 0.4, blue: 0.3, alpha: 1.0)) + } + + robot.assertEqual(\.fillDisplays.last?.red, UInt8(255 * 0.5)) + robot.assertEqual(\.fillDisplays.last?.green, UInt8(255 * 0.4)) + robot.assertEqual(\.fillDisplays.last?.blue, UInt8(255 * 0.3)) + } + + func test_fill_display_with_color_channel_values_larger_than_one() async throws { + await robot.operateDevice { + $0.fillDisplay(.init(red: 2.0, green: 2.0, blue: 2.0, alpha: 1.0)) + } + + robot.assertEqual(\.fillDisplays.last?.red, UInt8.max) + robot.assertEqual(\.fillDisplays.last?.green, UInt8.max) + robot.assertEqual(\.fillDisplays.last?.blue, UInt8.max) + } + + func test_fill_display_on_device_without_hardware_support() async throws { + robot.use(.mini) + + await robot.operateDevice { $0.fillDisplay(.green) } + + for index in 0 ..< robot.device.capabilities.keyCount { + await robot.assertSnapshot(\.keys[index].image, as: .image) + } + } + + func test_fill_display_should_replace_pending_drawing_operations() async throws { + await robot.operateDevice(isBusy: true) { device in + device.setImage(.colored(.blue)!, to: 1) + device.setFullscreenImage(.colored(.yellow)!) + device.fillDisplay(.init(red: 1, green: 1, blue: 1, alpha: 1.0)) + } + + robot.assertEqual(\.keys.count, 1) + robot.assertEqual(\.fullscreens.count, 0) + robot.assertEqual(\.fillDisplays.count, 1) + } + + // MARK: Set fullscreen image + + func test_set_fullscreen_image_should_set_scaled_fullscreen_image() async throws { + await robot.operateDevice { $0.setFullscreenImage(.colored(.blue)!, scaleAspectFit: false) } + + await robot.assertSnapshot(\.fullscreens.first!, as: .image) + } + + // MARK: Set touch area image + + func test_set_touch_area_image_should_set_scaled_touch_area_image() async throws { + robot.use(.plus) + + await robot.operateDevice { $0.setTouchAreaImage(.colored(.blue)!, scaleAspectFit: false) } + + await robot.assertSnapshot(\.touchAreaImages.first!.image, as: .image) + } + + func test_set_touch_area_image_with_rect_should_fill_specified_area() async throws { + robot.use(.plus) + + let expectedRect = CGRect(x: 12, y: 12, width: 42, height: 42) + + await robot.operateDevice { + $0.setTouchAreaImage(.colored(.blue)!, at: expectedRect) + } + + robot.assertEqual(\.touchAreaImages.first!.rect, expectedRect) + await robot.assertSnapshot(\.touchAreaImages.first!.image, as: .image) + } + + func test_set_touch_area_image_should_be_ignored_when_not_supported_by_device() async { + await robot.operateDevice { $0.setTouchAreaImage(.colored(.blue)!) } + + robot.assertEqual(\.touchAreaImages.count, 0) + } + + // MARK: Close + + func test_close_should_run_on_close_handler() { + let device = robot.device! + let closeExpectation = expectation(description: "closed") + device.onClose { closeExpectation.fulfill() } + device.close() + + wait(for: [closeExpectation], timeout: 1) + + XCTAssertTrue(device.isClosed) + } + + func test_operations_should_be_silently_ignored_after_close() { + let device = robot.device! + let closeExpectation = expectation(description: "closed") + device.onClose { closeExpectation.fulfill() } + device.close() + + wait(for: [closeExpectation], timeout: 1) + + device.enqueueOperation(.task { try? await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) }) + device.enqueueOperation(.task { try? await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) }) + + XCTAssertEqual(device.operationsQueue.count, 0) + + } + +} diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_left.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_left.png new file mode 100644 index 00000000..765c0ca2 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_left.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_right.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_right.png new file mode 100644 index 00000000..290873bc Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.dial_right.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_down.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_down.png new file mode 100644 index 00000000..e0190a5a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_down.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_up.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_up.png new file mode 100644 index 00000000..71eae0e8 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_dial_view.encoder_up.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_left.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_left.png new file mode 100644 index 00000000..928f0acf Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_left.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_right.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_right.png new file mode 100644 index 00000000..37cec8b6 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.dial_right.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_down.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_down.png new file mode 100644 index 00000000..779eaa34 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_down.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_up.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_up.png new file mode 100644 index 00000000..0315bc16 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_dial_rotate_and_click_on_touch_area.encoder_up.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_down.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_down.png new file mode 100644 index 00000000..64c8ded2 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_down.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_left.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_left.png new file mode 100644 index 00000000..76c9c921 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_left.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_right.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_right.png new file mode 100644 index 00000000..592ac134 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_right.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_up.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_up.png new file mode 100644 index 00000000..ec48dfd1 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_fling_on_touch_area.fling_up.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.fullscreen.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.fullscreen.png new file mode 100644 index 00000000..71272ea2 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.fullscreen.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_down.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_down.png new file mode 100644 index 00000000..84b5bbc8 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_down.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_up.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_up.png new file mode 100644 index 00000000..c61cbb74 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_key_down_up.key_up.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_render_initial_frame.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_render_initial_frame.1.png new file mode 100644 index 00000000..71272ea2 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_render_initial_frame.1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_0.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_0.png new file mode 100644 index 00000000..6d1d90a6 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_0.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_1.png new file mode 100644 index 00000000..4c64b4d6 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_2.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_2.png new file mode 100644 index 00000000..9eb601d3 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_2.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_3.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_3.png new file mode 100644 index 00000000..e70528e4 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_dial_section.section_3.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.30_20.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.30_20.png new file mode 100644 index 00000000..f9561313 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.30_20.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.80_10.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.80_10.png new file mode 100644 index 00000000..779fe3c4 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckLayoutTests/test_touch_on_touch_area.80_10.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.1.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.2.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.2.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.2.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.3.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.3.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.3.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.4.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.4.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.4.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.5.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.5.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.5.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.6.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.6.png new file mode 100644 index 00000000..06ccb32a Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_fill_display_on_device_without_hardware_support.6.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_fullscreen_image_should_set_scaled_fullscreen_image.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_fullscreen_image_should_set_scaled_fullscreen_image.1.png new file mode 100644 index 00000000..0a36c7fd Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_fullscreen_image_should_set_scaled_fullscreen_image.1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_image_on_key_should_set_scaled_image_on_key.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_image_on_key_should_set_scaled_image_on_key.1.png new file mode 100644 index 00000000..0f42c550 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_image_on_key_should_set_scaled_image_on_key.1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_should_set_scaled_touch_area_image.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_should_set_scaled_touch_area_image.1.png new file mode 100644 index 00000000..9013a6cc Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_should_set_scaled_touch_area_image.1.png differ diff --git a/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_with_rect_should_fill_specified_area.1.png b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_with_rect_should_fill_specified_area.1.png new file mode 100644 index 00000000..b46270b1 Binary files /dev/null and b/Tests/StreamDeckSDKTests/__Snapshots__/StreamDeckTests/test_set_touch_area_image_with_rect_should_fill_specified_area.1.png differ