diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepository.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepository.kt index 69ccd68..7219d1c 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepository.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepository.kt @@ -30,7 +30,7 @@ class ItemRepository @Inject constructor( } suspend fun getItemByBarcode(barcode: String): Item = withContext(dispatcher) { - apiService.getItem(barcode) + getCachedItems().firstOrNull { it.barcode == barcode } ?: apiService.getItem(barcode) } suspend fun refreshItems(): List = withContext(dispatcher) { diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/MainActivity.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/MainActivity.kt index 60e9c4c..52cc3fa 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/MainActivity.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/MainActivity.kt @@ -39,9 +39,10 @@ class MainActivity : ComponentActivity() { @SuppressLint("RestrictedApi") override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val input = decoder.onKey(event.action, event.keyCode) - if (input != null) { - barcodeEventBus.emit(input) + val result = decoder.onKey(event.action, event.keyCode, event.unicodeChar, event.eventTime) + result.input?.let { barcodeEventBus.emit(it) } + if (result.consumed) { + return true } return super.dispatchKeyEvent(event) } diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoder.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoder.kt index 9d4f7ae..5f3ebc1 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoder.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoder.kt @@ -2,28 +2,99 @@ package info.nukoneko.cuc.android.kidspos.ui.barcode import info.nukoneko.cuc.android.kidspos.util.BarcodeKind +data class BarcodeKeyResult( + val consumed: Boolean = false, + val input: BarcodeInput? = null +) + class BarcodeKeyEventDecoder { - private var readingValue: String = "" + private val reading = StringBuilder() + private val consumedKeys = mutableSetOf() + private var lastKeyTime = NO_KEY_TIME - fun onKey(action: Int, keyCode: Int): BarcodeInput? { + fun onKey( + action: Int, + keyCode: Int, + unicodeChar: Int, + eventTimeMillis: Long + ): BarcodeKeyResult { + if (action == ACTION_UP) { + return BarcodeKeyResult(consumed = consumedKeys.remove(keyCode)) + } if (action != ACTION_DOWN) { + return BarcodeKeyResult() + } + if (keyCode in MODIFIER_KEY_CODES) { + return BarcodeKeyResult() + } + if (keyCode in ENTER_KEY_CODES) { + return onEnter(keyCode) + } + return onCharacter(keyCode, unicodeChar, eventTimeMillis) + } + + private fun onEnter(keyCode: Int): BarcodeKeyResult { + val input = parse(reading.toString()) + reading.setLength(0) + lastKeyTime = NO_KEY_TIME + if (input == null) { + return BarcodeKeyResult() + } + consumedKeys += keyCode + return BarcodeKeyResult(consumed = true, input = input) + } + + private fun onCharacter( + keyCode: Int, + unicodeChar: Int, + eventTimeMillis: Long + ): BarcodeKeyResult { + val char = unicodeChar.toChar() + if (unicodeChar == 0 || !isBarcodeChar(char)) { + reading.setLength(0) + lastKeyTime = NO_KEY_TIME + return BarcodeKeyResult() + } + // 人が手で打つ速さでは読み取り機とみなさず、キーを画面側へ通す + val burst = reading.isNotEmpty() && eventTimeMillis - lastKeyTime <= SCAN_GAP_MILLIS + if (reading.length >= BARCODE_LENGTH) { + reading.setLength(0) + } + reading.append(char.uppercaseChar()) + lastKeyTime = eventTimeMillis + if (!burst) { + return BarcodeKeyResult() + } + consumedKeys += keyCode + return BarcodeKeyResult(consumed = true) + } + + private fun parse(value: String): BarcodeInput? { + if (value.length != BARCODE_LENGTH) { return null } - if (keyCode == KEYCODE_ENTER) { - var result: BarcodeInput? = null - if (readingValue.length == 10) { - val prefix = readingValue.substring(2, 4) - result = BarcodeInput(readingValue, BarcodeKind.prefixOf(prefix)) - } - readingValue = "" - return result - } - readingValue += (keyCode - 7).toString() - return null + val prefix = if (value.first().isDigit()) { + value.substring(NUMERIC_PREFIX_START, NUMERIC_PREFIX_START + PREFIX_LENGTH) + } else { + value.substring(ALPHA_PREFIX_START, ALPHA_PREFIX_START + PREFIX_LENGTH) + } + return BarcodeInput(value, BarcodeKind.prefixOf(prefix)) } + private fun isBarcodeChar(char: Char): Boolean = + char in '0'..'9' || char in 'A'..'Z' || char in 'a'..'z' + companion object { + const val SCAN_GAP_MILLIS = 200L + + private const val NO_KEY_TIME = 0L private const val ACTION_DOWN = 0 - private const val KEYCODE_ENTER = 66 + private const val ACTION_UP = 1 + private const val BARCODE_LENGTH = 10 + private const val PREFIX_LENGTH = 2 + private const val NUMERIC_PREFIX_START = 2 + private const val ALPHA_PREFIX_START = 1 + private val ENTER_KEY_CODES = setOf(66, 160) + private val MODIFIER_KEY_CODES = setOf(59, 60, 57, 58, 113, 114) } } diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepositoryTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepositoryTest.kt index 4a0f26e..59140c3 100644 --- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepositoryTest.kt +++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/ItemRepositoryTest.kt @@ -107,4 +107,40 @@ class ItemRepositoryTest { assertEquals("1001000001", repository.getItemByBarcode("1001000001").barcode) } + + @Test + fun getItemByBarcodeReturnsCachedItemWithoutCallingApi() = runTest { + val apiService = FakeAPIService() + val dataStore = FakePreferencesDataStore() + var apiCalls = 0 + apiService.getItemHandler = { barcode -> + apiCalls++ + item(9).copy(barcode = barcode) + } + apiService.fetchItemsHandler = { listOf(item(1), item(2)) } + val repository = + createItemRepository(apiService, StandardTestDispatcher(testScheduler), dataStore) + repository.refreshItems() + + assertEquals(item(2), repository.getItemByBarcode("1001000002")) + assertEquals(0, apiCalls) + } + + @Test + fun getItemByBarcodeFallsBackToApiWhenBarcodeIsNotCached() = runTest { + val apiService = FakeAPIService() + val dataStore = FakePreferencesDataStore() + var apiCalls = 0 + apiService.getItemHandler = { barcode -> + apiCalls++ + item(9).copy(barcode = barcode) + } + apiService.fetchItemsHandler = { listOf(item(1)) } + val repository = + createItemRepository(apiService, StandardTestDispatcher(testScheduler), dataStore) + repository.refreshItems() + + assertEquals("A01000008A", repository.getItemByBarcode("A01000008A").barcode) + assertEquals(1, apiCalls) + } } diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoderTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoderTest.kt index 6fe5004..4f17798 100644 --- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoderTest.kt +++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/barcode/BarcodeKeyEventDecoderTest.kt @@ -2,82 +2,175 @@ package info.nukoneko.cuc.android.kidspos.ui.barcode import info.nukoneko.cuc.android.kidspos.util.BarcodeKind import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class BarcodeKeyEventDecoderTest { private val decoder = BarcodeKeyEventDecoder() + private var now = 10_000L - private fun typeDigits(digits: String) { - digits.forEach { digit -> - assertNull(decoder.onKey(ACTION_DOWN, digit.digitToInt() + KEYCODE_0)) - } + private fun keyCodeOf(char: Char): Int = if (char in '0'..'9') { + char - '0' + KEYCODE_0 + } else { + char.uppercaseChar() - 'A' + KEYCODE_A + } + + private fun press(char: Char, gap: Long = SCAN_GAP): BarcodeKeyResult { + now += gap + val keyCode = keyCodeOf(char) + val down = decoder.onKey(ACTION_DOWN, keyCode, char.code, now) + decoder.onKey(ACTION_UP, keyCode, char.code, now) + return down + } + + private fun pressEnter(gap: Long = SCAN_GAP): BarcodeKeyResult { + now += gap + return decoder.onKey(ACTION_DOWN, KEYCODE_ENTER, 0, now) + } + + private fun type(value: String, gap: Long = SCAN_GAP) { + value.forEach { press(it, gap) } + } + + private fun scan(value: String, gap: Long = SCAN_GAP): BarcodeInput? { + type(value, gap) + return pressEnter(gap).input + } + + @Test + fun numericItemBarcodeIsDecoded() { + assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), scan("1001000000")) } @Test - fun tenDigitsFollowedByEnterProducesItemInput() { - typeDigits("1001000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result) + fun numericStaffBarcodeIsDecodedFromThirdAndFourthDigits() { + assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), scan("1000000000")) } @Test - fun staffPrefixIsDecodedFromThirdAndFourthDigits() { - typeDigits("1000000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), result) + fun numericSaleBarcodeIsDecoded() { + assertEquals(BarcodeInput("1002000000", BarcodeKind.SALE), scan("1002000000")) } @Test - fun salePrefixIsDecoded() { - typeDigits("1002000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1002000000", BarcodeKind.SALE), result) + fun unmappedNumericPrefixIsDecodedAsUnknown() { + assertEquals(BarcodeInput("1099000000", BarcodeKind.UNKNOWN), scan("1099000000")) } @Test - fun unmappedPrefixIsDecodedAsUnknown() { - typeDigits("1099000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1099000000", BarcodeKind.UNKNOWN), result) + fun alphanumericItemBarcodeIsDecoded() { + assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), scan("A01000008A")) } @Test - fun enterWithoutTenDigitsProducesNothing() { - typeDigits("123456789") - assertNull(decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)) + fun alphanumericStaffBarcodeIsDecodedFromSecondAndThirdCharacters() { + assertEquals(BarcodeInput("A00000001A", BarcodeKind.STAFF), scan("A00000001A")) + } + + @Test + fun alphanumericSaleBarcodeIsDecoded() { + assertEquals(BarcodeInput("A02000001A", BarcodeKind.SALE), scan("A02000001A")) + } + + @Test + fun unmappedAlphanumericPrefixIsDecodedAsUnknown() { + assertEquals(BarcodeInput("A99000001A", BarcodeKind.UNKNOWN), scan("A99000001A")) + } + + @Test + fun lowercaseInputIsNormalizedToUppercase() { + assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), scan("a01000008a")) + } + + @Test + fun shiftKeyBetweenCharactersDoesNotBreakTheRead() { + press('A') + now += SCAN_GAP + decoder.onKey(ACTION_DOWN, KEYCODE_SHIFT_LEFT, 0, now) + decoder.onKey(ACTION_UP, KEYCODE_SHIFT_LEFT, 0, now) + type("01000008A") + + assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), pressEnter().input) + } + + @Test + fun enterWithoutTenCharactersProducesNothing() { + assertNull(scan("123456789")) } @Test fun bufferIsClearedAfterIncompleteRead() { - typeDigits("123456789") - assertNull(decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)) - typeDigits("1001000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result) + assertNull(scan("123456789")) + + assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), scan("1001000000")) } @Test fun bufferIsClearedAfterSuccessfulRead() { - typeDigits("1001000000") - decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - typeDigits("1000000000") - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), result) + scan("1001000000") + + assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), scan("1000000000")) + } + + @Test + fun unsupportedCharacterClearsTheBuffer() { + type("A0100") + now += SCAN_GAP + decoder.onKey(ACTION_DOWN, KEYCODE_MINUS, '-'.code, now) + type("0008A") + + assertNull(pressEnter().input) + } + + @Test + fun scannedCharactersAreConsumedSoTheScreenDoesNotSeeThem() { + assertFalse(press('A').consumed) + assertTrue(press('0').consumed) + assertTrue(press('1').consumed) } @Test - fun nonDownActionsAreIgnored() { - typeDigits("1001000000") - assertNull(decoder.onKey(ACTION_UP, KEYCODE_ENTER)) - val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER) - assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result) + fun enterIsConsumedOnlyWhenABarcodeWasRead() { + type("123456789") + assertFalse(pressEnter().consumed) + + type("A01000008A") + assertTrue(pressEnter().consumed) + } + + @Test + fun keyUpOfAConsumedKeyIsAlsoConsumed() { + type("A0") + now += SCAN_GAP + assertTrue(decoder.onKey(ACTION_DOWN, keyCodeOf('1'), '1'.code, now).consumed) + assertTrue(decoder.onKey(ACTION_UP, keyCodeOf('1'), '1'.code, now).consumed) + } + + @Test + fun keyUpOfAnUntouchedKeyIsNotConsumed() { + assertFalse(decoder.onKey(ACTION_UP, KEYCODE_ENTER, 0, now).consumed) + } + + @Test + fun slowlyTypedCharactersAreNotConsumedButAreStillRead() { + val slowGap = BarcodeKeyEventDecoder.SCAN_GAP_MILLIS + 100L + "A01000008A".forEach { char -> + assertFalse(press(char, slowGap).consumed) + } + + assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), pressEnter(slowGap).input) } private companion object { const val ACTION_DOWN = 0 const val ACTION_UP = 1 const val KEYCODE_0 = 7 + const val KEYCODE_A = 29 + const val KEYCODE_SHIFT_LEFT = 59 const val KEYCODE_ENTER = 66 + const val KEYCODE_MINUS = 69 + const val SCAN_GAP = 10L } }