diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScannerContainer.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScannerContainer.swift index ce0d5fd263a..11b5e3dc685 100644 --- a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScannerContainer.swift +++ b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScannerContainer.swift @@ -39,79 +39,35 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable { let configuration: HIDBarcodeParserConfiguration let onScan: (Result) -> Void - func makeUIViewController(context: Context) -> BarcodeScannerHostingController { - let controller = BarcodeScannerHostingController( + func makeUIViewController(context: Context) -> UIViewController { + return GameControllerBarcodeScannerHostingController( configuration: configuration, onScan: onScan ) - return controller } - func updateUIViewController(_ uiViewController: BarcodeScannerHostingController, context: Context) {} + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } -/// A UIHostingController that handles keyboard input events for barcode scanning. -/// This controller captures keyboard input and interprets it as barcode data when a terminating -/// character is detected. -class BarcodeScannerHostingController: UIHostingController { - private let configuration: HIDBarcodeParserConfiguration - private let scanner: HIDBarcodeParser +/// A UIHostingController that handles GameController keyboard input events for barcode scanning. +/// This controller uses GameController framework exclusively for language-independent barcode scanning. +final class GameControllerBarcodeScannerHostingController: UIHostingController { + private var gameControllerBarcodeObserver: GameControllerBarcodeObserver? init( configuration: HIDBarcodeParserConfiguration, onScan: @escaping (Result) -> Void ) { - self.configuration = configuration - self.scanner = HIDBarcodeParser(configuration: configuration, - onScan: onScan) super.init(rootView: EmptyView()) + + gameControllerBarcodeObserver = GameControllerBarcodeObserver(configuration: configuration, onScan: onScan) } @MainActor required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override var canBecomeFirstResponder: Bool { true } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - becomeFirstResponder() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - resignFirstResponder() - } - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - /// We don't call super here because it helps prevent the system from hiding the software keyboard when - /// a textfield is next used. - } - - /// Handles the end of keyboard press events, interpreting them as barcode input. - /// When a terminating character is detected, the accumulated buffer is treated as a complete - /// barcode and passed to the onScan callback. - /// We don't call `super` here because we don't other responder chain items to handle our barcode as well, - /// as this could cause unexpected behavior. - override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { - /// While a scanner should just "press" each key once, in theory it's possible for presses to be cancelled - /// or change between the `began` call and the `ended` call. - /// It's better practice for barcode scanning to only consider the presses when they end. - for press in presses { - guard let key = press.key else { continue } - scanner.processKeyPress(key) - } - } - - override func pressesChanged(_ presses: Set, with event: UIPressesEvent?) { - super.pressesChanged(presses, with: event) - } - - /// `pressesCancelled` is rarely called, but Apple's documentation suggests it's possible and that crashes may occur if it's not handled. - /// It makes sense to clear the buffer when this happens. - /// We call super in case other presses are handled elsewhere in the responder chain. - override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { - scanner.cancel() - super.pressesCancelled(presses, with: event) + deinit { + gameControllerBarcodeObserver = nil } } diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeObserver.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeObserver.swift new file mode 100644 index 00000000000..d9f70ef4434 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeObserver.swift @@ -0,0 +1,115 @@ +import Foundation +import GameController + +/// An observer that uses the `GameController` framework to monitor for connected barcode scanners/keyboards +/// and parse their input into barcode strings. +/// +/// This class handles the low-level details of observing keyboard connections, processing raw `GCKeyCode` inputs, +/// and using the `GameControllerBarcodeParser` to produce a final barcode. +/// +final class GameControllerBarcodeObserver { + /// A closure that is called when a barcode scan is completed. + /// The result will be a `success` with the barcode string or a `failure` with an error. + let onScan: (Result) -> Void + + /// Track the coalesced keyboard and its parser + /// According to Apple's documentation, all connected keyboards are coalesced into one keyboard object + /// (GCKeyboard.coalesced), so notification about connection/disconnection will only be delivered once + /// until the last keyboard disconnects. + private var coalescedKeyboard: GCKeyboard? + private var barcodeParser: GameControllerBarcodeParser? + private let configuration: HIDBarcodeParserConfiguration + + /// Tracks current shift state to be applied to the next character key + private var isShiftPressed: Bool = false + + /// Initializes a new barcode scanner observer. + /// - Parameters: + /// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration. + /// - onScan: The closure to be called when a scan is completed. + init(configuration: HIDBarcodeParserConfiguration = .default, onScan: @escaping (Result) -> Void) { + self.onScan = onScan + self.configuration = configuration + addObservers() + setupCoalescedKeyboard() + } + + deinit { + removeObservers() + cleanupKeyboard() + } + + /// Starts observing for keyboard connection and disconnection events. + private func addObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardDidConnect), + name: .GCKeyboardDidConnect, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardDidDisconnect), + name: .GCKeyboardDidDisconnect, + object: nil + ) + } + + /// Stops observing for keyboard events. + private func removeObservers() { + NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidConnect, object: nil) + NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidDisconnect, object: nil) + } + + /// Sets up the coalesced keyboard if one is available at initialization. + private func setupCoalescedKeyboard() { + if let keyboard = GCKeyboard.coalesced { + setupKeyboard(keyboard) + } + } + + /// Handles the connection of a keyboard (coalesced). + @objc private func handleKeyboardDidConnect(_ notification: Notification) { + guard let keyboard = notification.object as? GCKeyboard else { + return + } + setupKeyboard(keyboard) + } + + /// Handles the disconnection of the keyboard (coalesced). + @objc private func handleKeyboardDidDisconnect(_ notification: Notification) { + cleanupKeyboard() + } + + /// Sets up the coalesced keyboard to handle key press events. + /// - Parameter keyboard: The coalesced `GCKeyboard` to set up. + private func setupKeyboard(_ keyboard: GCKeyboard) { + // Clean up any existing setup first + cleanupKeyboard() + + coalescedKeyboard = keyboard + barcodeParser = GameControllerBarcodeParser(configuration: configuration, onScan: onScan) + + keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in + guard let self = self else { return } + + if keyCode == .leftShift || keyCode == .rightShift { + self.isShiftPressed = pressed + return + } + + guard pressed else { return } + + self.barcodeParser?.processKeyPress(keyCode, isShiftPressed: isShiftPressed) + } + } + + /// Cleans up the coalesced keyboard and its parser. + private func cleanupKeyboard() { + coalescedKeyboard?.keyboardInput?.keyChangedHandler = nil + barcodeParser?.cancel() + coalescedKeyboard = nil + barcodeParser = nil + isShiftPressed = false + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift new file mode 100644 index 00000000000..44d50b8886f --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift @@ -0,0 +1,214 @@ +import Foundation +import GameController + +/// Parses GameController keyboard input into barcode scans. +/// This class handles the core logic for interpreting GameController GCKeyCode input as barcode data, +/// providing language-independent barcode scanning by bypassing iOS keyboard layout processing. +final class GameControllerBarcodeParser { + /// Configuration for the barcode scanner + let configuration: HIDBarcodeParserConfiguration + /// Callback that is triggered when a barcode scan completes (success or failure) + let onScan: (Result) -> Void + + private let timeProvider: TimeProvider + + private var buffer = "" + private var lastKeyPressTime: Date? + + init(configuration: HIDBarcodeParserConfiguration, + onScan: @escaping (Result) -> Void, + timeProvider: TimeProvider = DefaultTimeProvider()) { + self.configuration = configuration + self.onScan = onScan + self.timeProvider = timeProvider + } + + /// Process a GameController key press event + /// - Parameters: + /// - keyCode: The GameController key code that was pressed + /// - isShiftPressed: Whether shift key is currently pressed + func processKeyPress(_ keyCode: GCKeyCode, isShiftPressed: Bool = false) { + guard shouldRecogniseAsScanKeystroke(keyCode, isShiftPressed: isShiftPressed) else { + return + } + + guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed) else { + return + } + + if configuration.terminatingStrings.contains(character) { + processScan() + } else { + guard !excludedKeyCodes.contains(keyCode) else { return } + checkForTimeoutBetweenKeystrokes() + buffer.append(character) + } + } + + private func shouldRecogniseAsScanKeystroke(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> Bool { + guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed), character.isNotEmpty else { + // This prevents a double-trigger-pull on a Star scanner from adding an error row – + // Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is + // undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code. + // There seems to be no reason to handle empty input when considering scans. + return false + } + + if buffer.isEmpty && configuration.terminatingStrings.contains(character) { + // We prefer to show all partial scans, but if we just get an enter with no numbers, ignoring it makes testing easier + return false + } + + return true + } + + private func checkForTimeoutBetweenKeystrokes() { + // If characters are entered too slowly, it's probably typing and we should ignore the old input. + // The key we just received is still considered for adding to the buffer – we may simply reset the buffer first. + let currentTime = timeProvider.now() + + if let lastTime = lastKeyPressTime, + currentTime.timeIntervalSince(lastTime) > configuration.maximumInterCharacterTime { + onScan(.failure(HIDBarcodeParserError.timedOut(barcode: buffer))) + resetScan() + } + + lastKeyPressTime = currentTime + } + + /// Convert GCKeyCode to ASCII character + /// Maps GameController key codes to their corresponding ASCII characters + /// - Parameters: + /// - keyCode: The GameController key code + /// - isShiftPressed: Whether shift key is pressed (affects letter case and symbols) + private func characterForKeyCode(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> String? { + switch keyCode { + // Numbers and their shifted symbols + case .zero: return isShiftPressed ? ")" : "0" + case .one: return isShiftPressed ? "!" : "1" + case .two: return isShiftPressed ? "@" : "2" + case .three: return isShiftPressed ? "#" : "3" + case .four: return isShiftPressed ? "$" : "4" + case .five: return isShiftPressed ? "%" : "5" + case .six: return isShiftPressed ? "^" : "6" + case .seven: return isShiftPressed ? "&" : "7" + case .eight: return isShiftPressed ? "*" : "8" + case .nine: return isShiftPressed ? "(" : "9" + + // Letters - lowercase without shift, uppercase with shift + case .keyA: return isShiftPressed ? "A" : "a" + case .keyB: return isShiftPressed ? "B" : "b" + case .keyC: return isShiftPressed ? "C" : "c" + case .keyD: return isShiftPressed ? "D" : "d" + case .keyE: return isShiftPressed ? "E" : "e" + case .keyF: return isShiftPressed ? "F" : "f" + case .keyG: return isShiftPressed ? "G" : "g" + case .keyH: return isShiftPressed ? "H" : "h" + case .keyI: return isShiftPressed ? "I" : "i" + case .keyJ: return isShiftPressed ? "J" : "j" + case .keyK: return isShiftPressed ? "K" : "k" + case .keyL: return isShiftPressed ? "L" : "l" + case .keyM: return isShiftPressed ? "M" : "m" + case .keyN: return isShiftPressed ? "N" : "n" + case .keyO: return isShiftPressed ? "O" : "o" + case .keyP: return isShiftPressed ? "P" : "p" + case .keyQ: return isShiftPressed ? "Q" : "q" + case .keyR: return isShiftPressed ? "R" : "r" + case .keyS: return isShiftPressed ? "S" : "s" + case .keyT: return isShiftPressed ? "T" : "t" + case .keyU: return isShiftPressed ? "U" : "u" + case .keyV: return isShiftPressed ? "V" : "v" + case .keyW: return isShiftPressed ? "W" : "w" + case .keyX: return isShiftPressed ? "X" : "x" + case .keyY: return isShiftPressed ? "Y" : "y" + case .keyZ: return isShiftPressed ? "Z" : "z" + + // Punctuation and symbols with shift variants + case .spacebar: return " " + case .hyphen: return isShiftPressed ? "_" : "-" + case .equalSign: return isShiftPressed ? "+" : "=" + case .openBracket: return isShiftPressed ? "{" : "[" + case .closeBracket: return isShiftPressed ? "}" : "]" + case .backslash: return isShiftPressed ? "|" : "\\" + case .semicolon: return isShiftPressed ? ":" : ";" + case .quote: return isShiftPressed ? "\"" : "'" + case .comma: return isShiftPressed ? "<" : "," + case .period: return isShiftPressed ? ">" : "." + case .slash: return isShiftPressed ? "?" : "/" + case .graveAccentAndTilde: return isShiftPressed ? "~" : "`" + case .returnOrEnter: return "\r" + case .tab: return "\t" + default: + return nil + } + } + + /// Key codes that should be excluded from barcode processing + private let excludedKeyCodes: Set = [ + .capsLock, + .leftShift, + .rightShift, + .leftControl, + .rightControl, + .upArrow, + .downArrow, + .leftArrow, + .rightArrow, + .pageUp, + .pageDown, + .home, + .end, + .insert, + .deleteForward, + .deleteOrBackspace, + .printScreen, + .scrollLock, + .pause, + .escape + ] + + /// Cancel the current scan and clear the buffer + func cancel() { + resetScan() + } + + private func resetScan() { + buffer = "" + lastKeyPressTime = nil + } + + private func processScan() { + checkForTimeoutBetweenKeystrokes() + if buffer.count >= configuration.minimumBarcodeLength { + onScan(.success(buffer)) + } else { + onScan(.failure(HIDBarcodeParserError.scanTooShort(barcode: buffer))) + } + resetScan() + } +} + +/// Configuration options for the HID barcode parser +struct HIDBarcodeParserConfiguration { + /// Strings that indicate the end of a barcode scan + let terminatingStrings: Set + + /// Minimum length to consider scanned input complete + let minimumBarcodeLength: Int + + /// Maximum time between scanned keystrokes + /// After this time elapses, any further keystrokes result in the scan being rejected + let maximumInterCharacterTime: TimeInterval + + /// Default configuration suitable for most barcode scanners + static let `default` = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 6, + maximumInterCharacterTime: 0.2 + ) +} + +enum HIDBarcodeParserError: Error { + case scanTooShort(barcode: String) + case timedOut(barcode: String) +} diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/HIDBarcodeParser.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/HIDBarcodeParser.swift deleted file mode 100644 index e29d5c47426..00000000000 --- a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/HIDBarcodeParser.swift +++ /dev/null @@ -1,199 +0,0 @@ -import Foundation -import UIKit - -/// Parses HID (Human Interface Device) keyboard input into barcode scans. -/// This class handles the core logic for interpreting keyboard input as barcode data, -/// particularly useful for physical barcode scanners that operate as keyboard input devices. -final class HIDBarcodeParser { - /// Configuration for the barcode scanner - let configuration: HIDBarcodeParserConfiguration - /// Callback that is triggered when a barcode scan completes (success or failure) - let onScan: (Result) -> Void - - private let timeProvider: TimeProvider - - private var buffer = "" - private var lastKeyPressTime: Date? - - init(configuration: HIDBarcodeParserConfiguration, - onScan: @escaping (Result) -> Void, - timeProvider: TimeProvider = DefaultTimeProvider()) { - self.configuration = configuration - self.onScan = onScan - self.timeProvider = timeProvider - } - - /// Process a key press event - /// - Parameter key: The key that was pressed - func processKeyPress(_ key: UIKey) { - guard shouldRecogniseAsScanKeystroke(key) else { - return - } - - let character = key.characters - if configuration.terminatingStrings.contains(character) { - processScan() - } else { - guard !excludedKeys.contains(key.keyCode) else { return } - checkForTimeoutBetweenKeystrokes() - buffer.append(character) - } - } - - private func shouldRecogniseAsScanKeystroke(_ key: UIKey) -> Bool { - guard key.characters.isNotEmpty else { - // This prevents a double-trigger-pull on a Star scanner from adding an error row – - // Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is - // undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code. - // There seems to be no reason to handle empty input when considering scans. - return false - } - - if buffer.isEmpty && configuration.terminatingStrings.contains(key.characters) { - // We prefer to show all partial scans, but if we just get an enter with no numbers, ignoring it makes testing easier - return false - } - - return true - } - - private func checkForTimeoutBetweenKeystrokes() { - // If characters are entered too slowly, it's probably typing and we should ignore the old input. - // The key we just received is still considered for adding to the buffer – we may simply reset the buffer first. - let currentTime = timeProvider.now() - - if let lastTime = lastKeyPressTime, - currentTime.timeIntervalSince(lastTime) > configuration.maximumInterCharacterTime { - onScan(.failure(HIDBarcodeParserError.timedOut(barcode: buffer))) - resetScan() - } - - lastKeyPressTime = currentTime - } - - private let excludedKeys: [UIKeyboardHIDUsage] = [ - .keyboardCapsLock, - .keyboardF1, - .keyboardF2, - .keyboardF3, - .keyboardF4, - .keyboardF5, - .keyboardF6, - .keyboardF7, - .keyboardF8, - .keyboardF9, - .keyboardF10, - .keyboardF11, - .keyboardF12, - .keyboardPrintScreen, - .keyboardScrollLock, - .keyboardPause, - .keyboardInsert, - .keyboardHome, - .keyboardPageUp, - .keyboardDeleteForward, - .keyboardEnd, - .keyboardPageDown, - .keyboardRightArrow, - .keyboardLeftArrow, - .keyboardDownArrow, - .keyboardUpArrow, - .keypadNumLock, - .keyboardApplication, - .keyboardPower, - .keyboardF13, - .keyboardF14, - .keyboardF15, - .keyboardF16, - .keyboardF17, - .keyboardF18, - .keyboardF19, - .keyboardF20, - .keyboardF21, - .keyboardF22, - .keyboardF23, - .keyboardF24, - .keyboardExecute, - .keyboardHelp, - .keyboardMenu, - .keyboardSelect, - .keyboardStop, - .keyboardAgain, - .keyboardUndo, - .keyboardCut, - .keyboardCopy, - .keyboardPaste, - .keyboardFind, - .keyboardMute, - .keyboardVolumeUp, - .keyboardVolumeDown, - .keyboardLockingCapsLock, - .keyboardLockingNumLock, - .keyboardLockingScrollLock, - .keyboardAlternateErase, - .keyboardSysReqOrAttention, - .keyboardCancel, - .keyboardClear, - .keyboardPrior, - .keyboardSeparator, - .keyboardOut, - .keyboardOper, - .keyboardClearOrAgain, - .keyboardCrSelOrProps, - .keyboardExSel, - .keyboardLeftControl, - .keyboardLeftShift, - .keyboardLeftAlt, - .keyboardLeftGUI, - .keyboardRightControl, - .keyboardRightShift, - .keyboardRightAlt, - .keyboardRightGUI, - .keyboard_Reserved - ] - - /// Cancel the current scan and clear the buffer - func cancel() { - resetScan() - } - - private func resetScan() { - buffer = "" - lastKeyPressTime = nil - } - - private func processScan() { - checkForTimeoutBetweenKeystrokes() - if buffer.count >= configuration.minimumBarcodeLength { - onScan(.success(buffer)) - } else { - onScan(.failure(HIDBarcodeParserError.scanTooShort(barcode: buffer))) - } - resetScan() - } -} - -/// Configuration options for the HID barcode parser -struct HIDBarcodeParserConfiguration { - /// Strings that indicate the end of a barcode scan - let terminatingStrings: Set - - /// Minimum length to consider scanned input complete - let minimumBarcodeLength: Int - - /// Maximum time between scanned keystrokes - /// After this time elapses, any further keystrokes result in the scan being rejected - let maximumInterCharacterTime: TimeInterval - - /// Default configuration suitable for most barcode scanners - static let `default` = HIDBarcodeParserConfiguration( - terminatingStrings: ["\r", "\n"], - minimumBarcodeLength: 6, - maximumInterCharacterTime: 0.2 - ) -} - -enum HIDBarcodeParserError: Error { - case scanTooShort(barcode: String) - case timedOut(barcode: String) -} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 67c18a5a332..6303f06cc40 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -42,10 +42,13 @@ 015D99AA2C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */; }; 01620C4E2C5394B200D3EA2F /* POSProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */; }; 01664F9E2C50E685007CB5DD /* POSFontStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */; }; + 016910982E1D019500B731DA /* GameControllerBarcodeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */; }; 016A77692D9D24B00004FCD6 /* POSCouponCreationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016A77682D9D24A70004FCD6 /* POSCouponCreationSheet.swift */; }; 016C6B972C74AB17000D86FD /* POSConnectivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016C6B962C74AB17000D86FD /* POSConnectivityView.swift */; }; 0174DDBB2CE5FD60005D20CA /* ReceiptEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0174DDBA2CE5FD5D005D20CA /* ReceiptEmailViewModel.swift */; }; 0174DDBF2CE600C5005D20CA /* ReceiptEmailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0174DDBE2CE600C0005D20CA /* ReceiptEmailViewModelTests.swift */; }; + 0177250C2E1CFF7F00016148 /* GameControllerBarcodeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0177250B2E1CFF7F00016148 /* GameControllerBarcodeParser.swift */; }; + 0177250E2E1CFF9B00016148 /* GameControllerBarcodeParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0177250D2E1CFF9B00016148 /* GameControllerBarcodeParserTests.swift */; }; 0182C8BE2CE3B11300474355 /* MockReceiptEligibilityUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182C8BD2CE3B10E00474355 /* MockReceiptEligibilityUseCase.swift */; }; 0182C8C02CE4DDC700474355 /* CardReaderTransactionAlertReceiptState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182C8BF2CE4DDC100474355 /* CardReaderTransactionAlertReceiptState.swift */; }; 0182C8C22CE4F0DB00474355 /* ReceiptEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182C8C12CE4F0DB00474355 /* ReceiptEmailView.swift */; }; @@ -810,8 +813,6 @@ 20134CE62D4D1BDF00076A80 /* LearnMoreViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */; }; 20134CE82D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */; }; 20203AB22B31EEF1009D0C11 /* ExpandableBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */; }; - 202235F12DFAEAE500E13DE9 /* HIDBarcodeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202235F02DFAEAE500E13DE9 /* HIDBarcodeParser.swift */; }; - 202235F32DFAEC2700E13DE9 /* HIDBarcodeParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202235F22DFAEC2700E13DE9 /* HIDBarcodeParserTests.swift */; }; 202240FC2DFAF41D00E13DE9 /* BarcodeScanningModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202240FB2DFAF41D00E13DE9 /* BarcodeScanningModifier.swift */; }; 2023E2AE2C21D8EA00FC365A /* PointOfSaleCardPresentPaymentInLineMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2023E2AD2C21D8EA00FC365A /* PointOfSaleCardPresentPaymentInLineMessage.swift */; }; 2024966A2B0CC97100EE527D /* MockWooPaymentsDepositService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */; }; @@ -3200,10 +3201,13 @@ 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentLayout.swift; sourceTree = ""; }; 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProgressViewStyle.swift; sourceTree = ""; }; 01664F9D2C50E685007CB5DD /* POSFontStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFontStyle.swift; sourceTree = ""; }; + 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerBarcodeObserver.swift; sourceTree = ""; }; 016A77682D9D24A70004FCD6 /* POSCouponCreationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCouponCreationSheet.swift; sourceTree = ""; }; 016C6B962C74AB17000D86FD /* POSConnectivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSConnectivityView.swift; sourceTree = ""; }; 0174DDBA2CE5FD5D005D20CA /* ReceiptEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptEmailViewModel.swift; sourceTree = ""; }; 0174DDBE2CE600C0005D20CA /* ReceiptEmailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptEmailViewModelTests.swift; sourceTree = ""; }; + 0177250B2E1CFF7F00016148 /* GameControllerBarcodeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerBarcodeParser.swift; sourceTree = ""; }; + 0177250D2E1CFF9B00016148 /* GameControllerBarcodeParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerBarcodeParserTests.swift; sourceTree = ""; }; 0182C8BD2CE3B10E00474355 /* MockReceiptEligibilityUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockReceiptEligibilityUseCase.swift; sourceTree = ""; }; 0182C8BF2CE4DDC100474355 /* CardReaderTransactionAlertReceiptState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderTransactionAlertReceiptState.swift; sourceTree = ""; }; 0182C8C12CE4F0DB00474355 /* ReceiptEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptEmailView.swift; sourceTree = ""; }; @@ -3975,8 +3979,6 @@ 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreViewModelTests.swift; sourceTree = ""; }; 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardPresentPaymentPlugin+SetUpTapToPay.swift"; sourceTree = ""; }; 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableBottomSheet.swift; sourceTree = ""; }; - 202235F02DFAEAE500E13DE9 /* HIDBarcodeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIDBarcodeParser.swift; sourceTree = ""; }; - 202235F22DFAEC2700E13DE9 /* HIDBarcodeParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIDBarcodeParserTests.swift; sourceTree = ""; }; 202240FB2DFAF41D00E13DE9 /* BarcodeScanningModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScanningModifier.swift; sourceTree = ""; }; 2023E2AD2C21D8EA00FC365A /* PointOfSaleCardPresentPaymentInLineMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentInLineMessage.swift; sourceTree = ""; }; 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWooPaymentsDepositService.swift; sourceTree = ""; }; @@ -8304,7 +8306,8 @@ 20D557572DF9D57800D9EC8B /* Barcode Scanning */ = { isa = PBXGroup; children = ( - 202235F02DFAEAE500E13DE9 /* HIDBarcodeParser.swift */, + 016910972E1D019500B731DA /* GameControllerBarcodeObserver.swift */, + 0177250B2E1CFF7F00016148 /* GameControllerBarcodeParser.swift */, 20CEBF222E02C760001F3300 /* TimeProvider.swift */, 20D5575C2DFADF5400D9EC8B /* BarcodeScannerContainer.swift */, 202240FB2DFAF41D00E13DE9 /* BarcodeScanningModifier.swift */, @@ -8315,7 +8318,7 @@ 20D5575E2DFAE3D500D9EC8B /* Barcode Scanning */ = { isa = PBXGroup; children = ( - 202235F22DFAEC2700E13DE9 /* HIDBarcodeParserTests.swift */, + 0177250D2E1CFF9B00016148 /* GameControllerBarcodeParserTests.swift */, 20CEBF242E02C7E6001F3300 /* MockTimeProvider.swift */, ); path = "Barcode Scanning"; @@ -15075,6 +15078,7 @@ 451A9973260E39270059D135 /* ShippingLabelPackageNumberRow.swift in Sources */, AEE2610F26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift in Sources */, 025C00BA25514A7100FAC222 /* BarcodeScannerFrameScaler.swift in Sources */, + 016910982E1D019500B731DA /* GameControllerBarcodeObserver.swift in Sources */, EE9D031B2B89E4470077CED1 /* FilterOrdersByProduct+Analytics.swift in Sources */, 20C6E7512CDE4AEA00CD124C /* ItemListState.swift in Sources */, DEC17AE02D82C513005A6E6D /* WooShippingHazmatDetailView.swift in Sources */, @@ -15312,6 +15316,7 @@ 026A50282D2F6BD1002C42C2 /* InfiniteScrollTriggerDeterminable.swift in Sources */, 2004E2CA2C07771400D62521 /* CardPresentPaymentReaderConnectionResult.swift in Sources */, 860B85F12ADE3A0E00E85884 /* BulletPointView.swift in Sources */, + 0177250C2E1CFF7F00016148 /* GameControllerBarcodeParser.swift in Sources */, D817586222BB64C300289CFE /* OrderDetailsNotices.swift in Sources */, 022F7A0324A05F6400012601 /* LinkedProductsListSelectorViewController.swift in Sources */, 2602A63F27BD880A00B347F1 /* NewOrderInitialStatusResolver.swift in Sources */, @@ -15412,7 +15417,6 @@ EEC099382BF3C6A900FBCF6C /* MostActiveCouponsCardViewModel.swift in Sources */, B943E7252AFA41CF009CBA20 /* OrderDetailsCustomAmountCellViewModel.swift in Sources */, 45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */, - 202235F12DFAEAE500E13DE9 /* HIDBarcodeParser.swift in Sources */, 02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */, 68C7E5C42C69B3CD00856513 /* PointOfSaleItemListErrorLayout.swift in Sources */, AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */, @@ -17121,7 +17125,6 @@ 027B8BBD23FE0DE10040944E /* ProductImageActionHandlerTests.swift in Sources */, EEEDA916290A799E004B001D /* WordPressAuthenticator+Internal.swift in Sources */, CC53FB3E2758E2D500C4CA4F /* ProductRowViewModelTests.swift in Sources */, - 202235F32DFAEC2700E13DE9 /* HIDBarcodeParserTests.swift in Sources */, B5980A6521AC905C00EBF596 /* UIDeviceWooTests.swift in Sources */, FEEB2F61268A215E0075A6E0 /* StorageEligibilityErrorInfoWooTests.swift in Sources */, 207D2D232CFDCCBF00F79204 /* MockPOSOrderableItem.swift in Sources */, @@ -17437,6 +17440,7 @@ 02C2756F24F5F5EE00286C04 /* ProductShippingSettingsViewModel+ProductVariationTests.swift in Sources */, EEBB9B402D8FE5B6008D6CE5 /* WooShippingSplitShipmentsViewModelTests.swift in Sources */, 571FDDAE24C768DC00D486A5 /* MockZendeskManager.swift in Sources */, + 0177250E2E1CFF9B00016148 /* GameControllerBarcodeParserTests.swift in Sources */, 01AB2D122DDC7AD300AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift in Sources */, 45FBDF3C238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift in Sources */, 2609797C2A13D31500442249 /* PrivacyBannerPresentationUseCaseTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift new file mode 100644 index 00000000000..e151d4877bc --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift @@ -0,0 +1,616 @@ +import Testing +import GameController +@testable import WooCommerce + +struct GameControllerBarcodeParserTests { + + // MARK: - Configuration Tests + + struct ConfigurationTests { + @Test("default configuration has expected values") + func defaultConfiguration_whenRequested_hasExpectedValues() { + // Given + let configuration = HIDBarcodeParserConfiguration.default + + // When & Then + #expect(configuration.terminatingStrings == ["\r", "\n"]) + #expect(configuration.minimumBarcodeLength == 6) + #expect(configuration.maximumInterCharacterTime == 0.2) + } + + @Test("custom configuration accepts specified values") + func customConfiguration_whenCreated_acceptsSpecifiedValues() { + // Given + let customTerminators: Set = ["\t", " ", "\r"] + let customMinLength = 4 + let customMaxTime: TimeInterval = 0.1 + + // When + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: customTerminators, + minimumBarcodeLength: customMinLength, + maximumInterCharacterTime: customMaxTime + ) + + // Then + #expect(configuration.terminatingStrings == customTerminators) + #expect(configuration.minimumBarcodeLength == customMinLength) + #expect(configuration.maximumInterCharacterTime == customMaxTime) + } + } + + // MARK: - Basic Scanning Tests + + struct BasicScanningTests { + @Test("complete scan succeeds with valid barcode") + func validBarcode_whenScannedCompletely_succeeds() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123456") + } else { + Issue.record("Expected successful scan") + } + } + + @Test("multiple consecutive scans work correctly") + func multipleBarcodes_whenScannedConsecutively_workCorrectly() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - First scan + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // When - Second scan + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 2) + if case .success(let barcode1) = results[0] { + #expect(barcode1 == "123") + } else { + Issue.record("Expected first scan to succeed") + } + if case .success(let barcode2) = results[1] { + #expect(barcode2 == "456") + } else { + Issue.record("Expected second scan to succeed") + } + } + + @Test("cancelled scan clears buffer and allows new scan") + func partialScan_whenCancelled_clearsBufferAndAllowsNewScan() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Start a scan + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + + // When - Cancel the scan + parser.cancel() + + // When - Start a new scan + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "456") + } else { + Issue.record("Expected successful scan after cancel") + } + } + + // MARK: - Helper + static let testConfiguration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + } + + // MARK: - Error Handling Tests + + struct ErrorHandlingTests { + @Test("scan too short triggers error with default configuration") + func shortBarcode_whenScannedWithDefaultConfig_triggersError() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: .default, // min length 6 + onScan: { results.append($0) } + ) + + // When - Scan only 5 characters + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .failure(let error) = results.first { + if case HIDBarcodeParserError.scanTooShort(let barcode) = error { + #expect(barcode == "12345") + } else { + Issue.record("Expected scanTooShort error") + } + } else { + Issue.record("Expected failure result") + } + } + + @Test("scan too short triggers error with custom configuration") + func shortBarcode_whenScannedWithCustomConfig_triggersError() { + // Given + var results: [Result] = [] + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r"], + minimumBarcodeLength: 8, + maximumInterCharacterTime: 0.1 + ) + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) } + ) + + // When - Scan only 7 characters + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.seven) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .failure(let error) = results.first { + if case HIDBarcodeParserError.scanTooShort(let barcode) = error { + #expect(barcode == "1234567") + } else { + Issue.record("Expected scanTooShort error") + } + } else { + Issue.record("Expected failure result") + } + } + + @Test("slow typing triggers timeout error") + func slowTyping_whenExceedsTimeout_triggersError() { + // Given + var results: [Result] = [] + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.2 + ) + let mockTimeProvider = MockTimeProvider() + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) }, + timeProvider: mockTimeProvider + ) + + // When - Type slowly with timeout + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + mockTimeProvider.advance(by: 0.201) // Just over maximumInterCharacterTime + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then - Should get timeout error and successful scan + #expect(results.count == 2) + if case .failure(let error) = results.first { + if case HIDBarcodeParserError.timedOut(let barcode) = error { + #expect(barcode == "123") + } else { + Issue.record("Expected timedOut error") + } + } else { + Issue.record("Expected timeout failure") + } + + if case .success(let barcode) = results[1] { + #expect(barcode == "456") + } else { + Issue.record("Expected successful scan after timeout reset") + } + } + + @Test("fast typing within timeout succeeds") + func fastTyping_whenWithinTimeout_succeeds() { + // Given + var results: [Result] = [] + let configuration = HIDBarcodeParserConfiguration.default + let mockTimeProvider = MockTimeProvider() + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) }, + timeProvider: mockTimeProvider + ) + + // When - Type just under the timeout limit + parser.processKeyPress(GCKeyCode.one) + mockTimeProvider.advance(by: 0.199) // Just under maximumInterCharacterTime + parser.processKeyPress(GCKeyCode.two) + mockTimeProvider.advance(by: 0.199) + parser.processKeyPress(GCKeyCode.three) + mockTimeProvider.advance(by: 0.199) + parser.processKeyPress(GCKeyCode.four) + mockTimeProvider.advance(by: 0.199) + parser.processKeyPress(GCKeyCode.five) + mockTimeProvider.advance(by: 0.199) + parser.processKeyPress(GCKeyCode.six) + mockTimeProvider.advance(by: 0.199) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123456") + } else { + Issue.record("Expected successful scan") + } + } + + @Test("empty scan with only terminator is ignored") + func emptyBuffer_whenTerminatorSent_isIgnored() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Send only terminator + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.isEmpty) + } + + // MARK: - Helper + static let testConfiguration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + } + + // MARK: - Excluded Keys Tests + + struct ExcludedKeysTests { + @Test("modifier keys are excluded from scan input") + func modifierKeys_whenPressed_areExcludedFromScanInput() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Mix valid scan keys with excluded modifier keys + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.leftShift) // Should be ignored + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.rightShift) // Should be ignored + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.leftControl) // Should be ignored + parser.processKeyPress(GCKeyCode.rightControl)// Should be ignored + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan ignoring modifier keys") + } + } + + @Test("arrow keys are excluded from scan input") + func arrowKeys_whenPressed_areExcludedFromScanInput() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Mix valid scan keys with excluded arrow keys + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.upArrow) // Should be ignored + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.downArrow) // Should be ignored + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.leftArrow) // Should be ignored + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan ignoring arrow keys") + } + } + + @Test("function and system keys are excluded from scan input") + func systemKeys_whenPressed_areExcludedFromScanInput() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Mix valid scan keys with excluded system keys + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.capsLock) // Should be ignored + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.deleteOrBackspace) // Should be ignored + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.deleteForward) // Should be ignored + parser.processKeyPress(GCKeyCode.escape) // Should be ignored + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan ignoring system keys") + } + } + + @Test("navigation keys are excluded from scan input") + func navigationKeys_whenPressed_areExcludedFromScanInput() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Mix valid scan keys with excluded navigation keys + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.pageUp) // Should be ignored + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.pageDown) // Should be ignored + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.home) // Should be ignored + parser.processKeyPress(GCKeyCode.end) // Should be ignored + parser.processKeyPress(GCKeyCode.insert) // Should be ignored + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan ignoring navigation keys") + } + } + + // MARK: - Helper + static let testConfiguration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + } + + // MARK: - Terminator Tests + + struct TerminatorTests { + @Test("carriage return terminates scan") + func carriageReturn_whenPressed_terminatesScan() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.returnOrEnter) // \r + + // Then + #expect(results.count == 1) + if case .success(let barcode) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan with carriage return") + } + } + + @Test("multiple terminating strings work correctly") + func multipleTerminators_whenConfigured_workCorrectly() { + // Given + var results: [Result] = [] + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n", "\t", " "], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) } + ) + + // When - Test different terminators + // First scan with carriage return + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.returnOrEnter) // \r + + // Second scan with tab + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.tab) // \t + + // Third scan with space + parser.processKeyPress(GCKeyCode.seven) + parser.processKeyPress(GCKeyCode.eight) + parser.processKeyPress(GCKeyCode.nine) + parser.processKeyPress(GCKeyCode.spacebar) // space + + // Then + #expect(results.count == 3) + if case .success(let barcode1) = results[0] { + #expect(barcode1 == "123") + } else { + Issue.record("Expected first scan to succeed") + } + if case .success(let barcode2) = results[1] { + #expect(barcode2 == "456") + } else { + Issue.record("Expected second scan to succeed") + } + if case .success(let barcode3) = results[2] { + #expect(barcode3 == "789") + } else { + Issue.record("Expected third scan to succeed") + } + } + + @Test("terminator at start of empty buffer is ignored") + func emptyBuffer_whenTerminatorPressed_isIgnored() { + // Given + var results: [Result] = [] + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) } + ) + + // When - Send multiple terminators without data + parser.processKeyPress(GCKeyCode.returnOrEnter) + parser.processKeyPress(GCKeyCode.returnOrEnter) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Then + #expect(results.isEmpty) + } + + @Test("terminator in middle of scan is included in barcode") + func nonTerminatorCharacter_whenPressed_isIncludedInBarcode() { + // Given + var results: [Result] = [] + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\n"], // Only newline terminates + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) } + ) + + // When - Include carriage return in middle (not a terminator) + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.returnOrEnter) // \r (not terminator) + parser.processKeyPress(GCKeyCode.two) + // Note: We can't easily test \n vs \r distinction with GameController + // as .returnOrEnter maps to \r. This test demonstrates the concept. + + // Then - The scan should continue (no results yet) + #expect(results.isEmpty) + } + + @Test("parser does not start a timeout for an ignored character") + func emptyBuffer_whenIgnoredCharacterPressed_doesNotStartTimeout() { + // Given + var results: [Result] = [] + let mockTimeProvider = MockTimeProvider() + let parser = GameControllerBarcodeParser( + configuration: Self.testConfiguration, + onScan: { results.append($0) }, + timeProvider: mockTimeProvider + ) + + // When - Scan a barcode with two terminators, then scan another barcode + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.returnOrEnter) // Scan is recognised here + parser.processKeyPress(GCKeyCode.leftShift) // This should be ignored + + // Time between scans + mockTimeProvider.advance(by: 1.5) + + // Scan the second barcode + parser.processKeyPress(GCKeyCode.four) // Risk of an error row here if shift isn't ignored + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + parser.processKeyPress(GCKeyCode.leftShift) // This should also be ignored + + // Then + #expect(results.count == 2) + if case .success(let barcode1) = results[0] { + #expect(barcode1 == "123") + } else { + Issue.record("Expected success result for first scan") + } + if case .success(let barcode2) = results[1] { + #expect(barcode2 == "456") + } else { + Issue.record("Expected success result for second scan") + } + } + + // MARK: - Helper + static let testConfiguration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 3, + maximumInterCharacterTime: 0.05 + ) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/HIDBarcodeParserTests.swift b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/HIDBarcodeParserTests.swift deleted file mode 100644 index 10d28f0305b..00000000000 --- a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/HIDBarcodeParserTests.swift +++ /dev/null @@ -1,431 +0,0 @@ -import Testing -import UIKit -@testable import WooCommerce - -/// Tests for the HIDBarcodeParser class, which handles parsing of HID input events into barcode scans. -struct HIDBarcodeParserTests { - @Test("Default configuration uses standard terminating strings") - func testDefaultConfiguration() { - let configuration = HIDBarcodeParserConfiguration.default - #expect(configuration.terminatingStrings == ["\r", "\n"]) - #expect(configuration.minimumBarcodeLength == 6) - } - - @Test("Custom configuration uses specified terminating strings") - func testCustomConfiguration() { - let customTerminators: Set = ["\t", " "] - let configuration = HIDBarcodeParserConfiguration(terminatingStrings: customTerminators, - minimumBarcodeLength: 1, - maximumInterCharacterTime: 1) - #expect(configuration.terminatingStrings == customTerminators) - } - - @Test("Parser processes complete barcode scan") - func testCompleteScan() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // Simulate a complete scan - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .success(let barcode) = results.first { - #expect(barcode == "123") - } else { - Issue.record("Expected success result") - } - } - - @Test("Parser processes multiple scans") - func testMultipleScans() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // First scan - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "\r")) - - // Second scan - parser.processKeyPress(MockUIKey(character: "4")) - parser.processKeyPress(MockUIKey(character: "5")) - parser.processKeyPress(MockUIKey(character: "6")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 2) - if case .success(let barcode1) = results[0] { - #expect(barcode1 == "123") - } else { - Issue.record("Expected success result for first scan") - } - if case .success(let barcode2) = results[1] { - #expect(barcode2 == "456") - } else { - Issue.record("Expected success result for second scan") - } - } - - @Test("Parser handles cancelled scan") - func testCancelledScan() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // Start a scan - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - - // Cancel the scan - parser.cancel() - - // Start a new scan - parser.processKeyPress(MockUIKey(character: "4")) - parser.processKeyPress(MockUIKey(character: "5")) - parser.processKeyPress(MockUIKey(character: "6")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .success(let barcode) = results.first { - #expect(barcode == "456") - } else { - Issue.record("Expected success result") - } - } - - @Test("Parser notifies of scans below minimum length") - func testMinimumLength() { - var results: [Result] = [] - let configuration = HIDBarcodeParserConfiguration( - terminatingStrings: ["\r"], - minimumBarcodeLength: 4, - maximumInterCharacterTime: 0.1 - ) - let parser = HIDBarcodeParser( - configuration: configuration, - onScan: { result in - results.append(result) - } - ) - - // Try to scan a short barcode - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .failure(let error) = results.first { - if case HIDBarcodeParserError.scanTooShort(let barcode) = error { - #expect(barcode == "123") - } else { - Issue.record("Expected scanTooShort error") - } - } else { - Issue.record("Expected failure result") - } - } - - @Test("Parser notifies of slow typing timeout") - func testSlowTyping() { - var results: [Result] = [] - let configuration = HIDBarcodeParserConfiguration.default - let mockTimeProvider = MockTimeProvider() - let parser = HIDBarcodeParser( - configuration: configuration, - onScan: { result in - results.append(result) - }, - timeProvider: mockTimeProvider - ) - - // Simulate slow typing - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - mockTimeProvider.advance(by: 0.201) // Just over maximumInterCharacterTime - parser.processKeyPress(MockUIKey(character: "4")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 2) - if case .failure(let error) = results.first { - if case HIDBarcodeParserError.timedOut(let barcode) = error { - #expect(barcode == "123") - } else { - Issue.record("Expected timedOut error") - } - } else { - Issue.record("Expected failure result") - } - } - - @Test("Parser accepts slowish scans") - func testSlowScans() { - var results: [Result] = [] - let configuration = HIDBarcodeParserConfiguration.default - let mockTimeProvider = MockTimeProvider() - let parser = HIDBarcodeParser( - configuration: configuration, - onScan: { result in - results.append(result) - }, - timeProvider: mockTimeProvider - ) - - // Simulate slow typing - parser.processKeyPress(MockUIKey(character: "1")) - mockTimeProvider.advance(by: 0.199) // Just under maximumInterCharacterTime - parser.processKeyPress(MockUIKey(character: "2")) - mockTimeProvider.advance(by: 0.199) - parser.processKeyPress(MockUIKey(character: "3")) - mockTimeProvider.advance(by: 0.199) - parser.processKeyPress(MockUIKey(character: "4")) - mockTimeProvider.advance(by: 0.199) - parser.processKeyPress(MockUIKey(character: "5")) - mockTimeProvider.advance(by: 0.199) - parser.processKeyPress(MockUIKey(character: "6")) - mockTimeProvider.advance(by: 0.199) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .success(let barcode) = results.first { - #expect(barcode == "123456") - } else { - Issue.record("Expected success result") - } - } - - @Test("Parser handles multiple terminating strings") - func testMultipleTerminatingStrings() { - var results: [Result] = [] - let configuration = HIDBarcodeParserConfiguration( - terminatingStrings: ["\r", "\n", "\t"], - minimumBarcodeLength: 4, - maximumInterCharacterTime: 0.05 - ) - let parser = HIDBarcodeParser( - configuration: configuration, - onScan: { result in - results.append(result) - } - ) - - // Test with different terminating strings - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "4")) - parser.processKeyPress(MockUIKey(character: "\n")) - - parser.processKeyPress(MockUIKey(character: "5")) - parser.processKeyPress(MockUIKey(character: "6")) - parser.processKeyPress(MockUIKey(character: "7")) - parser.processKeyPress(MockUIKey(character: "8")) - parser.processKeyPress(MockUIKey(character: "\t")) - - #expect(results.count == 2) - if case .success(let barcode1) = results[0] { - #expect(barcode1 == "1234") - } else { - Issue.record("Expected success result for first scan") - } - if case .success(let barcode2) = results[1] { - #expect(barcode2 == "5678") - } else { - Issue.record("Expected success result for second scan") - } - } - - @Test("Parser ignores excluded keys as input") - func testExcludedKeysInput() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // Try to scan with some excluded keys mixed in - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "4", keyCode: .keyboardLeftShift)) - parser.processKeyPress(MockUIKey(character: "5", keyCode: .keyboardCapsLock)) - parser.processKeyPress(MockUIKey(character: "6", keyCode: .keyboardDownArrow)) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .success(let barcode) = results.first { - #expect(barcode == "123") - } else { - Issue.record("Expected success result") - } - } - - @Test("Parser allows excluded keys as terminators") - func testExcludedKeysTerminators() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // Try to scan with some excluded keys mixed in - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "\n", keyCode: .keyboardDownArrow)) - - #expect(results.count == 1) - if case .success(let barcode) = results.first { - #expect(barcode == "123") - } else { - Issue.record("Expected success result") - } - } - - @Test("Parser handles scan too short error with default configuration") - func testScanTooShortWithDefaultConfiguration() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: .default, - onScan: { result in - results.append(result) - } - ) - - // Try to scan a barcode that's too short for default config (min length 6) - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "4")) - parser.processKeyPress(MockUIKey(character: "5")) - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.count == 1) - if case .failure(let error) = results.first { - if case HIDBarcodeParserError.scanTooShort(let barcode) = error { - #expect(barcode == "12345") - } else { - Issue.record("Expected scanTooShort error") - } - } else { - Issue.record("Expected failure result") - } - } - - @Test("Parser does not show an error row for empty scan") - func testEmptyScanDoesntError() { - var results: [Result] = [] - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - } - ) - - // Just send the terminator, no scan input - parser.processKeyPress(MockUIKey(character: "\r")) - - #expect(results.isEmpty) - } - - @Test("Parser does not start a timeout for an ignored character") - func testEmptyScanDoesntStartTimeoutForIgnoredCharacter() { - var results: [Result] = [] - let mockTimeProvider = MockTimeProvider() - let parser = HIDBarcodeParser( - configuration: testConfiguration, - onScan: { result in - results.append(result) - }, - timeProvider: mockTimeProvider - ) - - // Scan a barcode with two terminators, then scan another barcode – only the two codes should be parsed - parser.processKeyPress(MockUIKey(character: "1")) - parser.processKeyPress(MockUIKey(character: "2")) - parser.processKeyPress(MockUIKey(character: "3")) - parser.processKeyPress(MockUIKey(character: "\r")) // Scan is recognised here - parser.processKeyPress(MockUIKey(character: "\n", keyCode: .keyboardDownArrow)) // This is ignored - - // Time between scans - mockTimeProvider.advance(by: 1.5) - - // Scan the second barcode - parser.processKeyPress(MockUIKey(character: "4")) // Risk of an error row here if `\n` isn't ignored - parser.processKeyPress(MockUIKey(character: "5")) - parser.processKeyPress(MockUIKey(character: "6")) - parser.processKeyPress(MockUIKey(character: "\r")) - parser.processKeyPress(MockUIKey(character: "\n", keyCode: .keyboardDownArrow)) - - - #expect(results.count == 2) - if case .success(let barcode1) = results[0] { - #expect(barcode1 == "123") - } else { - Issue.record("Expected success result for first scan") - } - if case .success(let barcode2) = results[1] { - #expect(barcode2 == "456") - } else { - Issue.record("Expected success result for second scan") - } - } -} - -// MARK: - Test Helpers - -private extension HIDBarcodeParserTests { - var testConfiguration: HIDBarcodeParserConfiguration { - HIDBarcodeParserConfiguration(terminatingStrings: ["\r", "\n"], - minimumBarcodeLength: 3, - maximumInterCharacterTime: 0.05) - } -} - -private class MockUIKey: UIKey { - private let mockCharacter: String - - // We use a default which won't be ignored when the key is evaluated. Control keys are ignored. - private let mockKeyCode: UIKeyboardHIDUsage - - init(character: String, keyCode: UIKeyboardHIDUsage = .keypad0) { - self.mockCharacter = character - self.mockKeyCode = keyCode - super.init() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var characters: String { - mockCharacter - } - - override var keyCode: UIKeyboardHIDUsage { - mockKeyCode - } -}