diff --git a/WooCommerce/Classes/POS/Models/Cart+BarcodeScanError.swift b/WooCommerce/Classes/POS/Models/Cart+BarcodeScanError.swift index f4577a72d58..644b63b41be 100644 --- a/WooCommerce/Classes/POS/Models/Cart+BarcodeScanError.swift +++ b/WooCommerce/Classes/POS/Models/Cart+BarcodeScanError.swift @@ -121,9 +121,9 @@ extension PointOfSaleBarcodeScanError { ) static let incompleteScan = NSLocalizedString( - "pointOfSale.barcodeScan.error.incompleteScan", - value: "Partial barcode scan", - comment: "Error message shown when scan is incomplete." + "pointOfSale.barcodeScan.error.incompleteScan.2", + value: "The scanner did not send an end-of-line character", + comment: "Error message shown when scanner times out without sending end-of-line character." ) static let parsingError = NSLocalizedString( diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift index e2f4c25409b..1438f9f4b02 100644 --- a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift +++ b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/GameControllerBarcodeParser.swift @@ -15,6 +15,7 @@ final class GameControllerBarcodeParser { private var buffer = "" private var lastKeyPressTime: Date? private var scanStartTime: Date? + private var timeoutTimer: Timer? init(configuration: HIDBarcodeParserConfiguration, onScan: @escaping (HIDBarcodeParserResult) -> Void, @@ -49,6 +50,7 @@ final class GameControllerBarcodeParser { } buffer.append(character) + scheduleTimeoutTimer() } } @@ -186,6 +188,31 @@ final class GameControllerBarcodeParser { buffer = "" lastKeyPressTime = nil scanStartTime = nil + cancelTimeoutTimer() + } + + private func scheduleTimeoutTimer() { + cancelTimeoutTimer() + timeoutTimer = timeProvider.scheduleTimer( + timeInterval: configuration.maximumInterCharacterTime, + target: self, + selector: #selector(handleTimeoutExpiry) + ) + } + + private func cancelTimeoutTimer() { + timeoutTimer?.invalidate() + timeoutTimer = nil + } + + @objc private func handleTimeoutExpiry() { + guard !buffer.isEmpty else { return } + + let scanDurationMs = calculateScanDurationMs() + let result = HIDBarcodeParserResult.failure(error: HIDBarcodeParserError.timedOut(barcode: buffer), scanDurationMs: scanDurationMs) + + onScan(result) + resetScan() } private func calculateScanDurationMs() -> Int { @@ -194,6 +221,7 @@ final class GameControllerBarcodeParser { } private func processScan() { + cancelTimeoutTimer() checkForTimeoutBetweenKeystrokes() let scanDurationMs = calculateScanDurationMs() diff --git a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/TimeProvider.swift b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/TimeProvider.swift index 3f786e7f173..470289929a2 100644 --- a/WooCommerce/Classes/POS/Presentation/Barcode Scanning/TimeProvider.swift +++ b/WooCommerce/Classes/POS/Presentation/Barcode Scanning/TimeProvider.swift @@ -2,10 +2,15 @@ import Foundation protocol TimeProvider { func now() -> Date + func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer } struct DefaultTimeProvider: TimeProvider { func now() -> Date { Date() } + + func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer { + return Timer.scheduledTimer(timeInterval: timeInterval, target: target, selector: selector, userInfo: nil, repeats: false) + } } diff --git a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift index bc3e4f2c10c..41ea06adc7d 100644 --- a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/GameControllerBarcodeParserTests.swift @@ -8,7 +8,7 @@ struct GameControllerBarcodeParserTests { struct ConfigurationTests { @Test("default configuration has expected values") - func defaultConfiguration_whenRequested_hasExpectedValues() { + func default_configuration_when_requested_has_expected_values() { // Given let configuration = HIDBarcodeParserConfiguration.default @@ -19,7 +19,7 @@ struct GameControllerBarcodeParserTests { } @Test("custom configuration accepts specified values") - func customConfiguration_whenCreated_acceptsSpecifiedValues() { + func custom_configuration_when_created_accepts_specified_values() { // Given let customTerminators: Set = ["\t", " ", "\r"] let customMinLength = 4 @@ -43,7 +43,7 @@ struct GameControllerBarcodeParserTests { struct BasicScanningTests { @Test("complete scan succeeds with valid barcode") - func validBarcode_whenScannedCompletely_succeeds() { + func valid_barcode_when_scanned_completely_succeeds() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -70,7 +70,7 @@ struct GameControllerBarcodeParserTests { } @Test("multiple consecutive scans work correctly") - func multipleBarcodes_whenScannedConsecutively_workCorrectly() { + func multiple_barcodes_when_scanned_consecutively_work_correctly() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -105,7 +105,7 @@ struct GameControllerBarcodeParserTests { } @Test("cancelled scan clears buffer and allows new scan") - func partialScan_whenCancelled_clearsBufferAndAllowsNewScan() { + func partial_scan_when_cancelled_clears_buffer_and_allows_new_scan() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -148,7 +148,7 @@ struct GameControllerBarcodeParserTests { struct ErrorHandlingTests { @Test("scan too short triggers error with default configuration") - func shortBarcode_whenScannedWithDefaultConfig_triggersError() { + func short_barcode_when_scanned_with_default_config_triggers_error() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -179,7 +179,7 @@ struct GameControllerBarcodeParserTests { } @Test("scan too short triggers error with custom configuration") - func shortBarcode_whenScannedWithCustomConfig_triggersError() { + func short_barcode_when_scanned_with_custom_config_triggers_error() { // Given var results: [HIDBarcodeParserResult] = [] let configuration = HIDBarcodeParserConfiguration( @@ -217,7 +217,7 @@ struct GameControllerBarcodeParserTests { } @Test("slow typing triggers timeout error") - func slowTyping_whenExceedsTimeout_triggersError() { + func slow_typing_when_exceeds_timeout_triggers_error() { // Given var results: [HIDBarcodeParserResult] = [] let configuration = HIDBarcodeParserConfiguration( @@ -264,7 +264,7 @@ struct GameControllerBarcodeParserTests { } @Test("fast typing within timeout succeeds") - func fastTyping_whenWithinTimeout_succeeds() { + func fast_typing_when_within_timeout_succeeds() { // Given var results: [HIDBarcodeParserResult] = [] let configuration = HIDBarcodeParserConfiguration.default @@ -299,8 +299,152 @@ struct GameControllerBarcodeParserTests { } } + @Test("proactive timeout triggers error without next character") + func timeout_when_timer_fires_triggers_timeout_error() { + // Given + var results: [HIDBarcodeParserResult] = [] + 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 partial barcode and simulate timer firing + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + + // Advance time beyond timeout period - this will automatically fire timers + mockTimeProvider.advance(by: 0.25) + + // Then - Should get timeout error automatically + #expect(results.count == 1) + if case .failure(let error, let duration) = results.first { + if case .timedOut(let barcode) = error { + #expect(barcode == "123") + #expect(duration >= 0) + } else { + Issue.record("Expected timedOut error") + } + } else { + Issue.record("Expected timeout failure") + } + } + + @Test("timer cancelled on successful scan completion") + func timer_cancelled_when_scan_completes_prevents_timeout_error() { + // Given + var results: [HIDBarcodeParserResult] = [] + 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 partial barcode then complete it before timer fires + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.returnOrEnter) // Complete scan before timeout + + // Try to advance time beyond timeout period - timer should not fire since it was cancelled + mockTimeProvider.advance(by: 0.25) + + // Then - Should only get success result, no timeout error + #expect(results.count == 1) + if case .success(let barcode, _) = results.first { + #expect(barcode == "123") + } else { + Issue.record("Expected successful scan") + } + } + + @Test("timer cancelled on manual scan cancellation") + func timer_cancelled_when_scan_cancelled_prevents_timeout_error() { + // Given + var results: [HIDBarcodeParserResult] = [] + 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 partial barcode then cancel before timer fires + parser.processKeyPress(GCKeyCode.one) + parser.processKeyPress(GCKeyCode.two) + parser.processKeyPress(GCKeyCode.three) + parser.cancel() // Cancel scan before timeout + + // Try to advance time beyond timeout period - timer should not fire since it was cancelled + mockTimeProvider.advance(by: 0.25) + + // Then - Should have no results since scan was cancelled + #expect(results.isEmpty) + } + + @Test("new character input cancels previous timer and starts new one") + func new_character_input_when_received_cancels_old_timer_and_starts_new() { + // Given + var results: [HIDBarcodeParserResult] = [] + let configuration = HIDBarcodeParserConfiguration( + terminatingStrings: ["\r", "\n"], + minimumBarcodeLength: 6, + maximumInterCharacterTime: 0.2 + ) + let mockTimeProvider = MockTimeProvider() + let parser = GameControllerBarcodeParser( + configuration: configuration, + onScan: { results.append($0) }, + timeProvider: mockTimeProvider + ) + + // When - Type characters with timing that would trigger timeout if timer wasn't reset + parser.processKeyPress(GCKeyCode.one) + + // Advance time by 0.15 seconds (less than timeout) and add next character + mockTimeProvider.advance(by: 0.15) + parser.processKeyPress(GCKeyCode.two) // This should cancel the first timer + + // Advance another 0.15 seconds (would be 0.3 total, but timer should have reset) + mockTimeProvider.advance(by: 0.15) + parser.processKeyPress(GCKeyCode.three) + parser.processKeyPress(GCKeyCode.four) + parser.processKeyPress(GCKeyCode.five) + parser.processKeyPress(GCKeyCode.six) + parser.processKeyPress(GCKeyCode.returnOrEnter) + + // Final advance to ensure no leftover timers fire + mockTimeProvider.advance(by: 0.1) + + // Then - Should get successful scan, not timeout + #expect(results.count == 1) + if case .success(let barcode, _) = results.first { + #expect(barcode == "123456") + } else { + Issue.record("Expected successful scan") + } + } + @Test("scan duration is properly tracked for successful scan") - func scanDuration_whenSuccessfulScan_isProperlyTracked() { + func scan_duration_when_successful_scan_is_properly_tracked() { // Given var results: [HIDBarcodeParserResult] = [] let mockTimeProvider = MockTimeProvider() @@ -333,7 +477,7 @@ struct GameControllerBarcodeParserTests { } @Test("scan duration is properly tracked for failed scan") - func scanDuration_whenFailedScan_isProperlyTracked() { + func scan_duration_when_failed_scan_is_properly_tracked() { // Given var results: [HIDBarcodeParserResult] = [] let mockTimeProvider = MockTimeProvider() @@ -365,7 +509,7 @@ struct GameControllerBarcodeParserTests { } @Test("empty scan with only terminator is ignored") - func emptyBuffer_whenTerminatorSent_isIgnored() { + func empty_buffer_when_terminator_sent_is_ignored() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -392,7 +536,7 @@ struct GameControllerBarcodeParserTests { struct ExcludedKeysTests { @Test("modifier keys are excluded from scan input") - func modifierKeys_whenPressed_areExcludedFromScanInput() { + func modifier_keys_when_pressed_are_excluded_from_scan_input() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -420,7 +564,7 @@ struct GameControllerBarcodeParserTests { } @Test("arrow keys are excluded from scan input") - func arrowKeys_whenPressed_areExcludedFromScanInput() { + func arrow_keys_when_pressed_are_excluded_from_scan_input() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -447,7 +591,7 @@ struct GameControllerBarcodeParserTests { } @Test("function and system keys are excluded from scan input") - func systemKeys_whenPressed_areExcludedFromScanInput() { + func system_keys_when_pressed_are_excluded_from_scan_input() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -475,7 +619,7 @@ struct GameControllerBarcodeParserTests { } @Test("navigation keys are excluded from scan input") - func navigationKeys_whenPressed_areExcludedFromScanInput() { + func navigation_keys_when_pressed_are_excluded_from_scan_input() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -515,7 +659,7 @@ struct GameControllerBarcodeParserTests { struct TerminatorTests { @Test("carriage return terminates scan") - func carriageReturn_whenPressed_terminatesScan() { + func carriage_return_when_pressed_terminates_scan() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -539,7 +683,7 @@ struct GameControllerBarcodeParserTests { } @Test("multiple terminating strings work correctly") - func multipleTerminators_whenConfigured_workCorrectly() { + func multiple_terminators_when_configured_work_correctly() { // Given var results: [HIDBarcodeParserResult] = [] let configuration = HIDBarcodeParserConfiguration( @@ -591,7 +735,7 @@ struct GameControllerBarcodeParserTests { } @Test("terminator at start of empty buffer is ignored") - func emptyBuffer_whenTerminatorPressed_isIgnored() { + func empty_buffer_when_terminator_pressed_is_ignored() { // Given var results: [HIDBarcodeParserResult] = [] let parser = GameControllerBarcodeParser( @@ -609,7 +753,7 @@ struct GameControllerBarcodeParserTests { } @Test("terminator in middle of scan is included in barcode") - func nonTerminatorCharacter_whenPressed_isIncludedInBarcode() { + func non_terminator_character_when_pressed_is_included_in_barcode() { // Given var results: [HIDBarcodeParserResult] = [] let configuration = HIDBarcodeParserConfiguration( @@ -634,7 +778,7 @@ struct GameControllerBarcodeParserTests { } @Test("parser does not start a timeout for an ignored character") - func emptyBuffer_whenIgnoredCharacterPressed_doesNotStartTimeout() { + func empty_buffer_when_ignored_character_pressed_does_not_start_timeout() { // Given var results: [HIDBarcodeParserResult] = [] let mockTimeProvider = MockTimeProvider() diff --git a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/MockTimeProvider.swift b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/MockTimeProvider.swift index b7bc11088a3..7df8ce4dba2 100644 --- a/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/MockTimeProvider.swift +++ b/WooCommerce/WooCommerceTests/POS/Presentation/Barcode Scanning/MockTimeProvider.swift @@ -1,8 +1,32 @@ import Foundation @testable import WooCommerce +final class MockTimer: Timer { + var isCancelled = false + weak var mockTimeProvider: MockTimeProvider? + let timerInterval: TimeInterval + let target: Any + let selector: Selector + let repeats: Bool + + init(timeInterval: TimeInterval, target: Any, selector: Selector, mockTimeProvider: MockTimeProvider) { + self.timerInterval = timeInterval + self.target = target + self.selector = selector + self.repeats = false + self.mockTimeProvider = mockTimeProvider + super.init() + } + + override func invalidate() { + isCancelled = true + mockTimeProvider?.removeTimer(self) + } +} + final class MockTimeProvider: TimeProvider { private var currentTime: Date + private var activeTimers: [MockTimer] = [] init(startTime: Date = Date(timeIntervalSince1970: 0)) { self.currentTime = startTime @@ -14,5 +38,33 @@ final class MockTimeProvider: TimeProvider { func advance(by interval: TimeInterval) { currentTime = currentTime.addingTimeInterval(interval) + + // Check and fire any timers that should have triggered during this time advancement + let timersToFire = activeTimers.filter { !$0.isCancelled && $0.timerInterval <= interval } + for timer in timersToFire { + _ = (timer.target as AnyObject).perform(timer.selector, with: timer.userInfo) + if !timer.repeats { + removeTimer(timer) + } + } + } + + func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer { + let mockTimer = MockTimer( + timeInterval: timeInterval, + target: target, + selector: selector, + mockTimeProvider: self + ) + activeTimers.append(mockTimer) + return mockTimer + } + + func removeTimer(_ timer: MockTimer) { + activeTimers.removeAll { $0 === timer } + } + + func clearScheduledTimers() { + activeTimers.removeAll() } }